fix(metadata): align MDT table services with the sub-directory bucketed layout - #19508
Draft
nsivabalan wants to merge 11 commits into
Draft
fix(metadata): align MDT table services with the sub-directory bucketed layout#19508nsivabalan wants to merge 11 commits into
nsivabalan wants to merge 11 commits into
Conversation
…ctory bucketing for global RLI Adds a pluggable layout SPI for the Hudi Metadata Table (MDT) so the on-disk organization of file groups can be customized without forking the writer or reader paths. Two implementations ship in OSS: - FlatMDTLayout (default): existing behavior — every file group lives directly under its MDT partition directory, with a single .hoodie_partition_metadata at the partition root. Bit-for-bit identical to the prior layout for tables that do not opt in. - SubDirBucketedMDTLayout (opt-in): file groups for non-partitioned MDT partitions (files, column_stats, bloom_filters, expression_index, secondary_index, and the global RLI) are grouped into bucket sub-directories (e.g. record_index/0000/, record_index/0001/) to lift the per-directory file count cap on HDFS-like filesystems. The single .hoodie_partition_metadata marker stays at the logical partition root so FSUtils.getAllPartitionPaths, direct Spark queries on the MDT, and hudi-cli all continue to see logical partition names rather than bucket sub-paths. Scope: - This patch supports the global RLI mode and all non-partitioned MDT partitions. Partitioned RLI is explicitly rejected at MDT initialization with a clear error message — the partitioned-RLI growth model needs a distinct bucketing strategy that lands in a separate follow-up patch / RFC. SPI surface (hudi-common): - HoodieMetadataTableLayout / LayoutContext / FileIdInfo - FlatMDTLayout, SubDirBucketedMDTLayout - HoodieMetadataTableLayouts factory (resolves via hoodie.metadata.layout.class on the MDT's own hoodie.properties) Reader plug points: - HoodieTableMetadataUtil.getPartitionFileSlices and getPartitionLatestFileSlicesIncludingInflight fan out across the layout's physical sub-paths. fileGroupCount is sourced from MDT-persisted properties (no FS listing on the read path). - FileSystemBackedTableMetadata.getAllFilesInPartition[s] are layout-aware for MDT base paths. - BaseHoodieTableFileIndex.filterFiles routes MDT partitions through the same util so direct Spark queries on the MDT path return correct results under bucketing. Writer plug points: - HoodieBackedTableMetadataWriter.initializeFileGroups consults the layout for fileId and relative path per file group; persists layout class + per- partition file-group counts on the MDT's hoodie.properties (only when a non-flat layout is in use, so default tables stay byte-identical). - HoodieBackedTableMetadataWriter.resolveLayoutForMDTInit fails fast with HoodieMetadataException when the user requests a non-flat layout while RLI is configured in the partitioned mode. - HoodieAppendHandle.doInit skips marker creation when writing to a layout sub-path on an MDT, so .hoodie_partition_metadata never appears inside a bucket directory. - getRecordTagger realigns the record's partitionPath with the file slice's physical bucket path under non-flat layouts. Tests: - TestHoodieMetadataTableLayout: 16 unit tests covering both layouts, fileId round-trips, bucket arithmetic at boundary values, partitioned-RLI rejection, marker placement. - TestMDTLayoutBucketing: parameterized end-to-end test running RLI workload under both layouts. Validates MDT-as-Hudi-table contract (logical partition names, no markers inside bucket dirs), correct RLI lookups, direct Spark scan returning rows. Both layouts pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rve flat-default time-travel
Two CI fixes for the HoodieMetadataTableLayout SPI:
1. Secondary/expression index bootstrap was failing because FlatMDTLayout
(and SubDirBucketedMDTLayout) computed the file-group relative path and
fileId from MetadataPartitionType.getPartitionPath(), which for SI/EI
returns the static prefix ("secondary_index_", "expr_index_") rather
than the real partition name ("secondary_index_idx0", "expr_index_idx_ts").
Thread the real relativePartitionPath through LayoutContext and use it in
both layouts; the writer's initializeFileGroups now passes it in.
2. BaseHoodieTableFileIndex.filterFiles previously short-circuited the
time-travel branch for any MDT, which silently changed
"MDT time-travel" semantics to "always latest" even for flat-default
tables that never opted into the new layout. Narrow the special case to
MDTs with a non-flat layout actually configured; flat-default keeps the
bit-identical pre-PR call to getLatestMergedFileSlicesBeforeOrOn.
Test calls to new LayoutContext(...) updated for the new constructor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n-flat layouts For flat-default tables (which existed before the layout SPI), the file slice's partition path always equals the record's MDT partition path, so the partition-path realignment in HoodieBackedTableMetadataWriter.getRecordTagger is semantically a no-op. Make that an explicit guard so flat-default tables take a bit-identical code path through the record tagger, instead of relying on an equality check inside the per-record lambda. Similarly, HoodieAppendHandle.isMDTLayoutSubPath now returns false up front when no non-flat MDT layout is configured on the table, so the 4-digit-suffix heuristic only runs for tables that have actually opted into bucketing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ices loop Trivial review-feedback pass: - HoodieMetadataConfig / HoodieTableConfig: list the OOB layout-class values (FlatMDTLayout, SubDirBucketedMDTLayout) and cross-reference hoodie.metadata.layout.class from the bucket-size doc so users can find the related knob. - FileIdInfo: add concrete fileId-to-FileIdInfo examples in the javadoc for the four shapes the SPI may parse (non-partitioned RLI, files, expression index, partitioned RLI). - HoodieTableMetadataUtil.getPartitionFileSlices: hoist the timeline lookups out of the per-physical-partition loop (they depend only on metaClient and stay invariant across iterations); replace the post-loop "any" flag with an early-return guard on the resolved physical-partition list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…init to ternary Both constructors had a duplicated if/else block setting mdtLayout and mdtLayoutPartitionFileGroupCounts based on whether the base path is an MDT. Collapse each to a single boolean lookup + ternary, removing the else branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…centralize bucket-sub-path heuristic
Addresses four review items:
- Move the bucket-sub-path heuristic out of HoodieAppendHandle into
HoodieTableMetadataUtil.isMDTBucketSubPath so any caller that needs to ask
"is this an MDT layout sub-path?" can share the same logic.
HoodieAppendHandle.isMDTLayoutSubPath becomes a one-line delegate.
- HoodieTableConfig.addMetadataLayoutPartitionFileGroupCounts now rejects any
attempt to overwrite an existing partition's count with a different value.
Re-stamping the same count is still idempotent. The intent: the on-disk
bucket layout for an existing MDT partition stays immutable so readers
cannot find it relocated under their feet. New MDT partitions added later
can still land freely.
- Rename the config key hoodie.metadata.layout.bucket.size to
hoodie.metadata.layout.bucketed.file.group.per.bucket. The new name reads
"how many file groups share one bucket" which matches the semantics. The
Java accessor getMetadataLayoutBucketSize() is unchanged.
- New tests:
* TestHoodieMetadataTableLayout adds 5 isMDTBucketSubPath cases
(non-MDT, flat-default-MDT, bucketed-MDT root, bucketed-MDT 4-digit
suffix, non-4-digit suffix).
* TestHoodieTableConfig adds 3 tests for the partition-file-group-count
map: initial round-trip + later partition append, idempotent re-stamp,
and the conflict-rejection assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… bucketing
Previous bucketing test ran only 3 commits, which is below the MDT
compaction trigger and below cleaner retention. cshuo and hudi-agent both
flagged that compaction (BaseHoodieCompactionPlanGenerator) and the
cleaner's full-listing path go through the FS view under the logical MDT
partition name, so under bucketing they could silently skip the bucketed
file groups entirely.
New test testMDTTableServicesWithBucketing runs 25 upsert commits with:
- SubDirBucketedMDTLayout enabled with bucketSize=2 (multiple buckets)
- hoodie.metadata.compact.max.delta.commits=5 so MDT compaction fires
multiple times during the run
- hoodie.clean.commits.retained=3 + auto-clean so the cleaner has work
to do early
and then asserts:
- At least one COMMIT_ACTION (compaction) instant lands on the MDT
timeline
- At least one cleaner instant lands on the data table timeline
- Bucket sub-directories still exist after compaction + cleaning
- Every bucket contains at least one HFile post-compaction (the
regression cshuo flagged would leave a bucket with only log files)
- The .hoodie_partition_metadata marker invariant still holds
(marker at logical root, not inside any bucket dir)
- Direct Spark scan on the MDT base path still returns rows
If the open compaction/cleaning fan-out concerns become real regressions,
this test will fire either zero-compaction-instants or HFile-absent on
the buckets that compaction skipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 6 digits
Two review-pass cleanups on the MDT layout SPI:
1. Rename SPI types with the Hoodie* prefix to match convention now (before the
SPI escapes into user code):
- LayoutContext -> HoodieMetadataLayoutContext
- FileIdInfo -> HoodieMetadataFileIdInfo
Sibling types (HoodieMetadataTableLayout, HoodieMetadataTableLayouts) already
carry the prefix. Cheap to rename now, painful once external implementors
depend on the current names.
2. Widen the bucket-directory index from %04d to %06d in SubDirBucketedMDTLayout.
The heuristic in HoodieTableMetadataUtil.isMDTBucketSubPath is bounded by the
same width and now consumes SubDirBucketedMDTLayout.BUCKET_INDEX_WIDTH as
the source of truth. With the old %04d ceiling, bucketIndex >= 10000 (10M
file groups at bucketSize=1000, or 10K at bucketSize=1) would produce a
5-digit suffix that the heuristic misreads as "not a bucket", causing a
spurious .hoodie_partition_metadata marker inside the bucket dir and
collapsing the "logical partition discovery" invariant. Widening to 6 digits
raises the ceiling to 1M buckets and adds a MAX_BUCKETS ValidationUtils gate
so overflow throws rather than silently misbehaves.
Tests updated to match: TestHoodieMetadataTableLayout (renamed
_trueForBucketedMDTWithFourDigitSuffix -> _trueForBucketedMDTWithSixDigitSuffix,
extended _falseForNonSixDigitSuffix to cover 7-digit) and
TestMDTLayoutBucketing (regex + %04d-formatted -> %06d-formatted).
Config documentation on HoodieMetadataConfig.METADATA_LAYOUT_CLASS and
HoodieTableConfig.METADATA_LAYOUT_CLASS updated from "4-digit bucket" to
"6-digit bucket" to stay consistent with the on-disk shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ket layout
The MDT write path is already keyed by physical partition paths under a
non-flat layout: getPartitionFileSlices expands logical -> physical before
querying the file system view, and getRecordTagger realigns each record's
partition path to its file slice's physical path. The table-service planners,
however, enumerate partitions from marker discovery / getAllPartitionPaths,
which return logical names because the single .hoodie_partition_metadata lives
at the logical partition root.
That divergence is not merely a compaction-execution failure. As soon as a
compaction is *requested*, the file system view's pending-compaction map is
keyed logically while every write-side lookup is keyed physically, so the join
misses silently:
- AbstractTableFileSystemView:250 phantom post-compaction slice is not
added, so MDT appends land in the slice being compacted and the appended
log blocks end up in neither the compaction input nor the resulting slice
(RLI entry loss).
- AbstractTableFileSystemView:1606 merged reads during a pending compaction
miss the map and drop pre-compaction log files (wrong index answers).
Expand at the planner boundaries so the plan key space matches the write side:
- HoodieTableMetadataUtil.expandToPhysicalPartitions: shared helper. No-op
for data tables and for the flat default layout, and idempotent for
already-physical input (a bucket sub-path has no entry in the persisted
file-group counts, so the layout returns it unchanged) -- which is what
keeps the incremental branch, whose partitions come from write stats,
correct.
- BaseTableServicePlanActionExecutor.getPartitions: covers both compaction
and log compaction, which share this chokepoint.
- CleanPlanner.getPartitionPathsForFullCleaning: expanded after the user
partition filters, so those keep matching logical names. Without this the
cleaner deletes log files an in-flight compaction still needs, wedging the
MDT.
- CleanPlanner.hasPendingFiles: listed the logical root directly, so it
always reported "no pending files" for a bucketed partition and a
partition with live pending files could be dropped wholesale.
- ListingBasedRollbackStrategy: the MDT is pinned to this strategy (DIRECT
markers, rollback-using-markers disabled) and the listing is
non-recursive, so rollback of a failed MDT compaction previously found
nothing to delete and "succeeded" while the orphan base file survived.
Two supporting fixes that the above make load-bearing:
- FileGroupReaderBasedMergeHandle.init now mirrors HoodieAppendHandle's
metafile-suppression guard. Compaction partitions are physical now, so
without it compaction writes a .hoodie_partition_metadata inside a bucket
directory, which makes partition discovery return bucket paths and breaks
the cleaner and rollback globally. The marker and the write stat continue
to derive from the same partition string, so reconcileAgainstMarkers
cannot mistake the freshly written base file for an orphan.
- HoodieBackedTableMetadataWriter.tagRecordsWithLocation built a
HoodieFileGroupId from the logical partition where its streaming twin uses
fileSlice.getFileGroupId(); it now uses the slice's own id so the file
group partition matches the physical partition its records carry.
Tests: unit coverage for the helper's fan-out, idempotency, de-duplication and
flat/non-MDT passthrough; functional coverage asserting on the persisted
compaction plan (every operation targets a bucket sub-path, never the logical
root, and the compacted buckets are a subset of what the layout enumerates),
on cleaning with incremental mode disabled so the logical full-listing path is
exercised, and on rollback partition enumeration via a non-recursive listing.
These assert plan-level and key-space properties rather than side effects,
since the failures this fixes leave the on-disk layout looking healthy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…MDT services
The existing bucketing tests assert on side effects -- bucket directories
survive, HFiles appear after compaction. That is too weak for this bug class:
when the planner's logical partition keys diverge from the write side's
physical ones, the file system view's pending-compaction bookkeeping misses
silently and the on-disk layout still looks healthy afterwards.
These assert the properties that actually distinguish a correct run:
- Compaction: every operation in every persisted plan targets a bucket
sub-path and never the logical partition root, and the set of compacted
buckets is a subset of what the layout enumerates. Reading each plan back
is part of the assertion. Also re-checks the marker invariant after
compaction, since a metafile written into a bucket directory would make
partition discovery return bucket paths.
- Cleaning: runs with incremental cleaning disabled so the cleaner takes the
full-listing path -- the one that enumerates logical names and joins them
against a physically-keyed pending-compaction map. Asserts cleaning and
compaction both fired (so they overlapped on the same buckets) and that no
bucket was stripped of its data files.
- Rollback: a non-recursive listing of each enumerated partition must find
data files, mirroring exactly what ListingBasedRollbackStrategy does. This
is the silent no-op -- rollback of a failed MDT compaction previously
"succeeded" while the orphan base file survived.
Unit coverage for expandToPhysicalPartitions: fan-out, idempotency for
already-physical input, de-duplication of mixed input, flat-layout and non-MDT
passthrough, and the uncounted-partition fallback.
Two assumptions corrected against observed behavior while writing these:
SubDirBucketedMDTLayout buckets every MDT partition rather than only the RLI,
so a single-file-group partition such as `files` lives at `files/000000` --
which means the rollback listing gap applies beyond the RLI. And a completed
MDT compaction appears on the timeline as a COMMIT_ACTION, with its plan still
readable at the same instant time.
Verified: TestMDTLayoutBucketing 6/6 via surefire; TestHoodieMetadataTableLayout
28/28; TestCleanPlanner and the rollback suites 75/75.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g assertions
Self-review follow-ups on the two commits before this.
Production:
- CleanPlanner.hasPendingFiles leaked its HoodieTableFileSystemView. That was
pre-existing, but expanding to a per-bucket loop makes the view longer-lived,
so switch to try-with-resources.
- ListingBasedRollbackStrategy: use the local metaClient rather than calling
table.getMetaClient() again for the same object.
Tests:
- The "compaction spans more than one bucket" assertion was written as
`size >= 1`, which the preceding recordIndexOpsSeen check already guarantees
-- the comment claimed something stronger than the code checked, and it would
have passed even with partially broken expansion. Tightened to `> 1`, which is
what bucketSize=2 over this workload actually produces.
- Corrected a comment claiming every compaction plan "must exist and parse"
where the catch block in fact skips unreadable ones. The skip is needed (not
every COMMIT_ACTION instant has a plan behind it), so the comment now says so.
- Dropped the dataFilePath assertion: it pinned an Avro schema convention rather
than anything this change affects.
Verified: TestMDTLayoutBucketing 6/6, TestCleanPlanner + rollback suites 75/75.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Change Logs
Follow-up to #19045, which introduced the
HoodieMetadataTableLayoutSPI and the opt-inSubDirBucketedMDTLayout. That PR left the MDT table services misaligned with the bucketed on-disk layout. This aligns them.The problem. Under a non-flat layout the MDT write path is already keyed by physical partition paths:
getPartitionFileSlicesexpands logical → physical before querying the file system view, andgetRecordTaggerrealigns each record's partition path to its file slice's physical path. The table-service planners, however, enumerate partitions from marker discovery /getAllPartitionPaths, which return logical names — the single.hoodie_partition_metadatalives at the logical partition root by design.That divergence is not only a compaction-execution failure. As soon as a compaction is requested, the FSV's pending-compaction map is keyed logically while every write-side lookup is keyed physically, so the join misses silently:
AbstractTableFileSystemView:250— the phantom post-compaction file slice is not added, so MDT appends land in the slice being compacted and those log blocks end up in neither the compaction input nor the resulting slice → RLI entry loss.AbstractTableFileSystemView:1606(fetchMergedFileSlice) — merged reads during a pending compaction miss the map and drop pre-compaction log files → wrong index answers.The fix — expand at the planner boundaries so the plan key space matches the already-physical write side:
HoodieTableMetadataUtil.expandToPhysicalPartitionsBaseTableServicePlanActionExecutor.getPartitionsCleanPlanner.getPartitionPathsForFullCleaningCleanPlanner.hasPendingFilesListingBasedRollbackStrategyIdempotency is the property that keeps the incremental branch correct: a physical sub-path such as
record_index/000003has no entry in the persisted per-partition file-group counts, so the count resolves to 0 and the layout returns the input unchanged. Incremental table services source partitions from write stats, which are already physical.Two supporting fixes the above make load-bearing:
FileGroupReaderBasedMergeHandle.initnow mirrorsHoodieAppendHandle's metafile-suppression guard. Compaction partitions are physical now, so without it compaction writes a.hoodie_partition_metadatainside a bucket directory — which makes partition discovery return bucket paths and breaks the cleaner and rollback globally. The marker and the write stat still derive from the same partition string, soreconcileAgainstMarkerscannot mistake the freshly written base file for an orphan.HoodieBackedTableMetadataWriter.tagRecordsWithLocationbuilt aHoodieFileGroupIdfrom the logical partition where its streaming twin usesfileSlice.getFileGroupId(); it now uses the slice's own id.Scope. No public contract, storage format, or config change. Every path is a no-op for data tables and for MDTs on the flat default layout, which is what every pre-existing table uses.
Tests
The bucketing tests on #19045 assert on side effects — bucket directories survive, HFiles appear after compaction. That is too weak for this bug class: when the key spaces diverge the corruption is silent and the on-disk layout still looks healthy. The tests here assert plan-level and key-space properties instead:
testMDTCompactionPlansCarryPhysicalPartitions) — every operation in every persisted plan targets a bucket sub-path and never the logical partition root; compacted buckets are a subset of what the layout enumerates; compaction spans more than one bucket atbucketSize=2(so the fan-out is genuinely exercised); the marker invariant still holds afterwards.testMDTCleaningUnderBucketingWithFullCleaning) — runs with incremental cleaning disabled, forcing the full-listing path that enumerates logical names and joins them against a physically-keyed pending-compaction map. Asserts cleaning and compaction both fired (so they overlapped on the same buckets) and that no bucket was stripped of its data files.testRollbackEnumeratesPhysicalBucketPartitions) — a non-recursive listing of each enumerated partition must find data files, mirroring exactly whatListingBasedRollbackStrategydoes. This is the silent no-op.TestHoodieMetadataTableLayout) — fan-out, idempotency for already-physical input, de-duplication of mixed input, flat-layout and non-MDT passthrough, uncounted-partition fallback, and single-bucket partitions.Two things the runs corrected in my own initial assumptions, both now encoded in tests:
SubDirBucketedMDTLayoutbuckets every MDT partition rather than only the RLI (a single-file-group partition such asfileslives atfiles/000000), which means the rollback listing gap applies beyond the RLI; and a completed MDT compaction appears on the timeline as aCOMMIT_ACTION, with its plan still readable at the same instant time.Local results:
TestMDTLayoutBucketing6/6,TestHoodieMetadataTableLayout28/28,TestCleanPlanner+ rollback suites 75/75,TestHoodieTableMetadataUtil+TestMetadataPartitionType47/47.Impact
Makes MDT compaction, log compaction, cleaning and rollback correct under
SubDirBucketedMDTLayout. No behavior change for the flat default layout or for data tables.Risk level: medium
Touches shared table-service planner code. The risk is contained by the layout gate —
expandToPhysicalPartitionsreturns its input unchanged unless the table is an MDT on a non-flat layout — so data-table and flat-MDT paths are bit-identical.TestCleanPlannerand the rollback suites (75 tests) pass unchanged, which is the main regression signal for the shared code.One residual worth stating plainly:
expandToPhysicalPartitionsis now load-bearing for the two FSV windows above. A future planner boundary that forgets the helper reintroduces a silent failure. The tests below pin the current boundaries, but the structural fix for that class of risk is the sibling-partition layout, which removes the logical/physical split on the service path rather than defending it. That is a larger redesign and belongs in its own RFC.Documentation Update
None needed — no user-facing config or contract change.
Contributor's checklist