Skip to content

fix(flink): align bucket pruning with field-prefixed record keys - #20314

Open
wombatu-kun wants to merge 3 commits into
apache:masterfrom
wombatu-kun:fix-flink-bucket-pruning-prefixed-key
Open

wombatu-kun wants to merge 3 commits into
apache:masterfrom
wombatu-kun:fix-flink-bucket-pruning-prefixed-key

Conversation

@wombatu-kun

@wombatu-kun wombatu-kun commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Closes #20313.

On a Flink bucket-index table with one primary key and two or more partition fields, an equality filter on the primary key returns no rows: the complex key generator stores the key as uuid:id1 (FIELD_PREFIXED) and the bucket writers hash that, while PrimaryKeyPruners.getBucketIdFunc hashed the bare literal id1.

Summary and Changelog

  • HoodieTableSource reads the record key encoding recorded in the table config and hashes <field>:<value> when both the table and the job's key generator prefix. When the table records FIELD_PREFIXED but the job would not prefix, the stored keys can take either form (Spark prefixes, Flink with an explicit complex key generator and one partition field does not), so the pruning is skipped, as it is for a table without a recorded encoding (no data files are read at planning time) and for a streaming read before the table exists.
  • RowDataKeyGen#isFieldPrefixedSingleRecordKey exposes the writer's prefix decision to the pruner.
  • PrimaryKeyPruners#getBucketIdFunc hashes the prefixed key through BucketIdentifier.getBucketId(recordKey, indexKeyFields, numBuckets), as the writers do.
  • KeyGenUtils.extractRecordKeysByFields gets the missing keyValueSep2 > 0 guard, so a skipped trailing value holding : (e.g. 'a,b:c:d') no longer loops forever; only inputs that never returned change.
  • Tests: ITTestHoodieDataSource reproduces both table shapes with Flink SQL on COW and MOR, TestHoodieTableSource checks the pruning bucket against the writer's key generator and the skipped cases, and TestKeyGenUtils covers the parser.

Impact

Primary key equality queries on these tables return their rows again. A FIELD_PREFIXED table read by a job that would not prefix, such as one Spark wrote with one partition field, now reads every bucket and finds its rows. Other key generators, compound keys and VALUE_ONLY tables keep their bucket ids. No storage format, API or configuration change.

Risk Level

Low. The bucket changes only when the table and the job both prefix, and then follows the writers' hashing; a table without a recorded encoding is read without bucket pruning until its next write or upgrade records it.

Verified: the multi-partition IT fails on master and the single-partition IT on this PR's first revision ee336b06e3e1, both with Expected: is "[+I[id1]]" but: was "[]", and each new unit-test assertion fails with its part of the fix removed. Merges cleanly with #20292.

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

@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 working on this! This makes Flink bucket pruning hash a field-prefixed single record key the same way the bucket writers store it, and also fixes the infinite loop in extractRecordKeysByFields; one non-blocking point is flagged inline about tables where the recorded encoding and the job's key generator disagree. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. One suggestion on keeping getDataBucketFunc readable.

}
// the Flink key generator prefixes by the job configuration, which can disagree with the table's encoding
// (an explicit complex key generator with a single partition field writes bare keys), so require both
boolean prefixedSingleFieldKey = encodingOpt.map(ComplexKeyGenEncoding::encodesFieldName).orElse(false)

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.

🤖 non-blocking: Spark's ComplexAvroKeyGenerator prefixes keys whenever the table records FIELD_PREFIXED, even with a single partition field. So a Spark-written single-partition complex-keygen bucket table read from Flink takes the bare-hash branch here, lands on the wrong bucket, and uuid = 'x' returns nothing. Since we can't tell which writer produced the keys when the table encoding says prefixed but isFieldPrefixedSingleRecordKey(conf) says bare, would it make sense to return Option.empty() in that case instead of hashing the bare value?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

@wombatu-kun wombatu-kun Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done d05f8af

return Option.empty();
}
return Option.of(PrimaryKeyPruners.getBucketIdFunc(indexKeyFilters, conf));
if (metaClient == null) {

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.

🤖 nit: would it make sense to move the encoding resolution and the prefixedSingleFieldKey decision into a small helper (e.g. resolvePrefixedSingleFieldKey() returning Option<Boolean>)? That would keep getDataBucketFunc focused on the filter check and bucket function.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

@wombatu-kun wombatu-kun Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done d05f8af

@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Oct 14, 2026
@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:M PR with lines of changes in (100, 300] labels Oct 14, 2026
*
* @return empty when the form of the stored record keys cannot be determined, which skips the bucket pruning
*/
private Option<Boolean> resolvePrefixedSingleFieldKey() {

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.

The HoodieTableConfig.COMPLEX_KEYGEN_ENCODING is already setup in table factory, can we just decode it and avoid to call KeyGenUtils.resolveComplexKeyGenEncoding which is costly.

@wombatu-kun wombatu-kun Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done c2fb5ea: the helper reads only the encoding recorded in the table config the meta client has already loaded, and skips the pruning for a table that has not recorded it yet instead of deducing it from the data. The conf alone cannot replace that check: a FIELD_PREFIXED table keyed by Spark and read by a single-partition job would return no rows (ITTestHoodieDataSource#testBucketPruningWithPrefixedKeysAndSinglePartitionField).

@codecov-commenter

codecov-commenter commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 80.71%. Comparing base (61dde86) to head (c2fb5ea).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...rg/apache/hudi/source/prune/PrimaryKeyPruners.java 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #20314      +/-   ##
============================================
+ Coverage     80.46%   80.71%   +0.25%     
- Complexity    35045    35162     +117     
============================================
  Files          2553     2553              
  Lines        143547   143567      +20     
  Branches      17478    17483       +5     
============================================
+ Hits         115506   115887     +381     
+ Misses        20076    19736     -340     
+ Partials       7965     7944      -21     
Components Coverage Δ
hudi-common 84.18% <ø> (+0.06%) ⬆️
hudi-client 83.97% <100.00%> (+0.09%) ⬆️
hudi-flink 86.08% <95.83%> (+1.63%) ⬆️
hudi-spark-datasource 74.19% <ø> (ø)
hudi-utilities 78.15% <ø> (-0.04%) ⬇️
hudi-cli 70.80% <ø> (ø)
hudi-hadoop 71.16% <ø> (-0.02%) ⬇️
hudi-sync 76.58% <ø> (-0.05%) ⬇️
hudi-io 81.52% <ø> (ø)
hudi-timeline-service 83.41% <ø> (-0.25%) ⬇️
hudi-cloud 81.00% <ø> (ø)
hudi-kafka-connect 54.48% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 52.58% <92.00%> (+<0.01%) ⬆️
flink-integration-tests 49.69% <92.00%> (+13.31%) ⬆️
hadoop-mr-java-client 43.94% <0.00%> (-0.01%) ⬇️
integration-tests 13.44% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 38.67% <0.00%> (-0.01%) ⬇️
spark-java-tests 52.80% <0.00%> (+0.01%) ⬆️
spark-scala-tests 47.76% <0.00%> (-0.02%) ⬇️
utilities 36.88% <0.00%> (+<0.01%) ⬆️

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

Files with missing lines Coverage Δ
.../main/java/org/apache/hudi/keygen/KeyGenUtils.java 87.73% <100.00%> (+2.68%) ⬆️
.../java/org/apache/hudi/sink/bulk/RowDataKeyGen.java 94.91% <100.00%> (+0.17%) ⬆️
.../java/org/apache/hudi/table/HoodieTableSource.java 94.80% <100.00%> (+0.52%) ⬆️
...rg/apache/hudi/source/prune/PrimaryKeyPruners.java 90.90% <80.00%> (-3.21%) ⬇️

... and 63 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.

@wombatu-kun
wombatu-kun force-pushed the fix-flink-bucket-pruning-prefixed-key branch from 78f2420 to c2fb5ea Compare October 14, 2026 08:18

@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 working on this! This PR makes Flink bucket pruning hash the same <field>:<value> key the writers store for single-key complex keygen tables, skips pruning when the stored key form is unclear, and fixes the infinite loop in extractRecordKeysByFields. No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. One small readability suggestion below; the rest looks clean.

cc @yihua

*
* @return empty when the form of the stored record keys cannot be determined, which skips the bucket pruning
*/
private Option<Boolean> resolvePrefixedSingleFieldKey() {

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.

🤖 nit: could you replace Option<Boolean> with a small enum (e.g. PREFIXED, BARE, UNKNOWN)? Here empty means "skip pruning" and false means "bare keys", which is easy to misread at the call site.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

// Spark writes `<field>:<value>` keys into a FIELD_PREFIXED table, while the Flink key generator follows the job
// configuration and writes bare keys for an explicit complex key generator with a single partition field, so
// when this job would not prefix, the stored keys can take either form
return RowDataKeyGen.isFieldPrefixedSingleRecordKey(conf) ? Option.of(true) : Option.empty();

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.

just this check should be enough? the option HoodieTableConfig.COMPLEX_KEYGEN_ENCODING was inferred statically no matter whether the table exists.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not for a table keyed by Spark, which follows the recorded encoding rather than a job conf (KeyGenUtils#withComplexKeyGenEncoding). A reading job without an encoding option would hash 1 on a Spark ComplexKeyGenerator table with one partition field, which stores id:1, and id:1 on a legacy VALUE_ONLY table with two partition fields, which stores 1, so with the conf alone both key queries return no rows; the recorded value is already loaded by the meta client and keeps both correct.

This branch has not been deployed

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

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] flink: bucket pruning misses rows on tables with one primary key and multiple partition fields

4 participants