Skip to content

fix(utilities): include metadata table index-init instants in the record index validation snapshot - #19395

Open
Davis-Zhang-Onehouse wants to merge 1 commit into
apache:masterfrom
Davis-Zhang-Onehouse:fix-mdt-validator-record-index-init-instant
Open

fix(utilities): include metadata table index-init instants in the record index validation snapshot#19395
Davis-Zhang-Onehouse wants to merge 1 commit into
apache:masterfrom
Davis-Zhang-Onehouse:fix-mdt-validator-record-index-init-instant

Conversation

@Davis-Zhang-Onehouse

@Davis-Zhang-Onehouse Davis-Zhang-Onehouse commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

HoodieMetadataTableValidator reports 100% of a table's record keys as missing from the record
index
on table version 6 tables whose record index is complete and correct on storage:

Validation of record index content failed: <N> keys (total <N>) from the data table have wrong
location in record index metadata. ... Record key <k> -> FS: (,<fileId>), Record Index: <empty>

The validator reads the metadata table with a time-travel snapshot anchored to the data table's
latest completed commit:

String latestCompletedCommit = metaClient.getActiveTimeline().getCommitsAndCompactionTimeline()
    .filterCompletedInstants().lastInstant().get().requestedTime();
...
.option(TIME_TRAVEL_AS_OF_INSTANT.key(), latestCompletedCommit).load(getMetadataTableBasePath(basePath))

On a table version 6 metadata table, the partition-initialization deltacommits are that same data
instant with a three-digit suffix appended —
HoodieBackedTableMetadataWriterTableVersionSix#createIndexInitTimestamp:

return String.format("%s%03d", timestamp, PARTITION_INITIALIZATION_TIME_SUFFIX + offset);

so FILES becomes <instant>010 and RECORD_INDEX becomes <instant>011. Hudi compares instants as
strings (InstantComparison#LESSER_THAN_OR_EQUALS is c1.compareTo(c2) <= 0), so those derived
instants sort after the bare data instant:

"20231012054834279011".compareTo("20231012054834279") == 3   // > 0

When the data table has no commit newer than the initialization instant, the snapshot therefore
excludes every record-index file slice — HoodieFileGroup#getLatestFileSliceBeforeOrOn filters them
all out and the record index reads back empty.

It reproduces whenever a version 6 metadata table is initialized on a table that already has commits
and no further commit follows: the window between a metadata table (re)bootstrap and the next write,
and permanently for a table that has stopped receiving writes. Cleans and rollbacks do not clear it,
because getCommitsAndCompactionTimeline() resolves to getWriteTimeline(), which whitelists only
commit, deltacommit, compaction, logcompaction and replacecommit.

Table version 8 and above are not affectedHoodieBackedTableMetadataWriter#generateUniqueInstantTime
derives the init instants from SOLO_COMMIT_TIMESTAMP via instantTimePlusMillis, which sorts below
every real data instant. This fixes the reader for the version 6 tables that predate that change.

Summary and Changelog

Users running HoodieMetadataTableValidator against table version 6 tables no longer get spurious
record-index validation failures. Genuine record-index divergence is still reported.

Advance the instant used for the metadata-table read by one millisecond, via a new
metadataTableInstantFor(String) helper. That is strictly greater than any <instant><suffix>
those share the whole 17-character prefix and so compare lower — while remaining a valid
yyyyMMddHHmmssSSS instant. The valid-instant part matters: the time travel option runs the value
through HoodieSqlCommonUtils#formatQueryInstant, which accepts only yyyyMMddHHmmssSSS,
yyyy-MM-dd HH:mm:ss.SSS or yyyy-MM-dd and throws IllegalArgumentException on anything else, so a
synthetic 20-character bound is not an option. Non-timestamp instants (legacy or test instants such as
100) are passed through unchanged.

The data-table side of the comparison is untouched, so both sides stay pinned to the same data instant.

Both affected reads are fixed:

  • validateRecordIndexContentgetRecordLocationsFromRLI
  • validateRecordIndexCount — the same defect, previously masked because the content check shadows it
    via the else if in validateRecordIndex

Not changed, and why:

  • the files partition — validateFilesInPartition reads through HoodieBackedTableMetadata, not the
    Spark datasource, so it never applied the time-travel bound
  • the secondary-index reads — they pass latestCompletedCommit too, but load the data table, so they
    are correct as-is

No code was copied.

Impact

No public API, config, or output-format change. No behaviour change for tables that receive commits
after metadata table initialization, and none for table version 8+.

Risk Level

low

Verified red→green with a new test that reproduces the state: a table written with
hoodie.write.table.version=6 and the metadata table disabled, then bootstrapped out of band through
SparkMetadataWriterFactory — so the version 6 writer is selected, as in production — leaving the init
instants at <latestDataCommit>010/011 while the data table's latest completed commit stays
<latestDataCommit>. The test asserts HoodieTableVersion.SIX explicitly so it cannot silently pass
if a future change stops producing a version 6 table. Parameterized over both record-index validation
paths.

Before the fix:

  • content path — Validation of record index content failed: 50 keys (total 50) ... Record Index: <empty>
  • count path — Validation of record index count failed: 0 entries from record index metadata, 50 keys from the data table

After the fix both pass, and the full TestHoodieMetadataTableValidator class is green (44/44) on
Temurin 11.

The test asserts on validateRecordIndex directly rather than only on run(). That is deliberate:
doMetadataTableValidation catches any non-HoodieValidationException and returns true, reporting a
failed read as a successful validation, so an assertion on run() alone cannot tell a genuine pass
from a swallowed failure. That swallow is pre-existing and is not addressed here; it looks worth a
separate fix, since it can mask real validation errors in production the same way.

Documentation Update

none

Contributor's checklist

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

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Jul 28, 2026
…ord index validation snapshot

HoodieMetadataTableValidator reads the metadata table with a time-travel
snapshot anchored to the data table's latest completed commit. On a table
version 6 metadata table the partition-initialization deltacommits are that
same data instant with a three-digit suffix appended (010 for FILES, 011 for
RECORD_INDEX), and Hudi compares instants as strings, so those derived
instants sort after the bare data instant and fall outside the snapshot.

When the data table has no commit newer than the initialization instant,
every record index file slice is filtered out, the index reads back empty,
and the validator reports 100% of the data table's keys as missing from it.
This is permanent for a table that has stopped receiving writes; cleans and
rollbacks do not help because getWriteTimeline() only whitelists commit,
deltacommit, compaction, logcompaction and replacecommit.

Table version 8 and above are unaffected: generateUniqueInstantTime derives
the init instants from SOLO_COMMIT_TIMESTAMP, which sorts below every data
instant.

Advance the instant used for the metadata table read by one millisecond.
That is strictly greater than any <instant><suffix>, which shares the whole
17-character prefix, while remaining a valid yyyyMMddHHmmssSSS instant - the
time travel option runs the value through formatQueryInstant, which rejects
anything else. The data table side is unchanged, so both sides stay pinned to
the same data instant.

Fixes both validateRecordIndexContent and validateRecordIndexCount; the
latter carried the same defect, masked only because the content check
shadows it.
@Davis-Zhang-Onehouse
Davis-Zhang-Onehouse force-pushed the fix-mdt-validator-record-index-init-instant branch from 3328c52 to b4e7b44 Compare July 28, 2026 23:29

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for the contribution! This PR fixes HoodieMetadataTableValidator falsely reporting all record keys as missing on table-version-6 tables by advancing the metadata-table time-travel bound one millisecond past the data instant, so the suffixed partition-init and maintenance deltacommits fall inside the queried snapshot. The boundary reasoning holds under string comparison, arbitrary suffix lengths, and rollovers, and the tests cover both the count and content validation paths plus the non-timestamp fallback. No issues flagged from this automated pass; a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@codecov-commenter

codecov-commenter commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.52%. Comparing base (8e18999) to head (b4e7b44).

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19395      +/-   ##
============================================
+ Coverage     72.49%   72.52%   +0.02%     
- Complexity    32888    32896       +8     
============================================
  Files          2574     2574              
  Lines        149010   149015       +5     
  Branches      18749    18749              
============================================
+ Hits         108032   108075      +43     
+ Misses        32513    32479      -34     
+ Partials       8465     8461       -4     
Components Coverage Δ
hudi-common 82.26% <ø> (+0.02%) ⬆️
hudi-client 81.81% <ø> (+0.02%) ⬆️
hudi-flink 78.63% <ø> (+0.01%) ⬆️
hudi-spark-datasource 58.67% <ø> (-0.01%) ⬇️
hudi-utilities 71.42% <100.00%> (+0.19%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.49% <ø> (ø)
hudi-sync 70.62% <ø> (ø)
hudi-io 79.57% <ø> (ø)
hudi-timeline-service 84.23% <ø> (+0.78%) ⬆️
hudi-cloud 64.00% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (-0.77%) ⬇️
Flag Coverage Δ
common-and-other-modules 47.42% <0.00%> (-0.01%) ⬇️
flink-integration-tests 47.63% <ø> (+<0.01%) ⬆️
hadoop-mr-java-client 43.38% <ø> (+<0.01%) ⬆️
integration-tests 13.53% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 48.71% <ø> (+<0.01%) ⬆️
spark-java-tests 49.38% <0.00%> (-0.02%) ⬇️
spark-scala-tests 44.64% <0.00%> (-0.01%) ⬇️
utilities 36.37% <100.00%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...e/hudi/utilities/HoodieMetadataTableValidator.java 66.66% <100.00%> (+2.11%) ⬆️

... and 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Davis-Zhang-Onehouse

Copy link
Copy Markdown
Contributor Author

The one red check here, test-hudi-hadoop-mr-and-hudi-java-client (scala-2.12, spark3.5, flink2.1), looks unrelated to this change and appears to be a flaky test. Could a committer re-run that job?

Failing test:

TestJavaHoodieBackedMetadata.testReattemptOfFailedClusteringCommit
  Files within partition 2015/03/16 should match ==> expected: <true> but was: <false>

Why it shouldn't be reachable from this PR:

  • the diff is two files, both in hudi-utilities
  • hudi-client/hudi-java-client/pom.xml and hudi-hadoop-mr/pom.xml declare no hudi-utilities dependency, and neither module references HoodieMetadataTableValidator
  • the same job passed on master at this PR's base commit 8e18999a4164, and master has not advanced since (so the merge commit contains only the two-file change)

What I checked locally: testReattemptOfFailedClusteringCommit passes 3/3 both with and without the change on an equivalent 1.x tree, so my environment does not reproduce it either way — that rules out a deterministic regression but does not by itself prove flakiness.

Stronger signal: on a separate CI system running the same 1.x code, this exact test failed and then passed on a re-run of the identical commit with no code change in between. I can't link that run since it isn't public, so please weigh it accordingly — the verifiable parts above are the module-dependency argument and the green master run at the same base.

Everything else on this PR is green, including all Flink, Spark, bundle, docker and integration-test shards.

@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

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

@nsivabalan

Copy link
Copy Markdown
Contributor

@Davis-Zhang-Onehouse : can you check the CI failure.

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

Labels

size:M PR with lines of changes in (100, 300]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants