Skip to content

[MINOR] Do not return a negative Spark partition when a hash is Integer.MIN_VALUE - #19776

Open
PDGGK wants to merge 2 commits into
apache:masterfrom
PDGGK:fix-negative-spark-partition
Open

[MINOR] Do not return a negative Spark partition when a hash is Integer.MIN_VALUE#19776
PDGGK wants to merge 2 commits into
apache:masterfrom
PDGGK:fix-negative-spark-partition

Conversation

@PDGGK

@PDGGK PDGGK commented Aug 28, 2026

Copy link
Copy Markdown

Describe the issue this Pull Request addresses

Two Spark Partitioner implementations derive the partition index as Math.abs(hash) % numPartitions:

// CoalescingPartitioner:44
return Math.abs(key.hashCode()) % numPartitions;

// PartitionPathRDDPartitioner:50
return Math.abs(Objects.hash(partitionPathExtractor.apply(o))) % numPartitions;

Math.abs(Integer.MIN_VALUE) is Integer.MIN_VALUE, so the expression stays negative whenever numPartitions does not divide 2^31 — that is, for every parallelism that is not a power of two. Partitioner#getPartition has to answer inside [0, numPartitions).

Both are reachable from ordinary data:

partitioner input 2 3 4 5 7 8
CoalescingPartitioner key "polygenelubricants" (hashCode is Integer.MIN_VALUE) 0 -2 0 -3 -2 0
PartitionPathRDDPartitioner partition path "xfjfxsf" 0 -2 0 -3 -2 0

Objects.hash(x) is 31 + x.hashCode(), so the second one needs a partition path hashing to 2147483617 for the sum to overflow to Integer.MIN_VALUE; "xfjfxsf" is such a value.

Summary and Changelog

Both now use Math.floorMod, which is non-negative for every input. This is the same expression Spark's own HashPartitioner uses (Utils.nonNegativeMod), and the one JavaUpsertPartitioner already uses; BucketIndexUtil avoids Math.abs the same way with (hash & Integer.MAX_VALUE) % parallelism.

Tests, both asserting against org.apache.spark.HashPartitioner as the oracle rather than only checking the index is in range — a range check passes for the old expression too on most inputs, which is not a strong enough assertion here:

  • TestCoalescingPartitioner#testPartitionMatchesSparkHashPartitioner — added to the existing class; asserts the fixture still hashes to Integer.MIN_VALUE first, so it cannot silently stop exercising that case, then compares against the oracle for negative and non-negative keys at 1..16 partitions.
  • TestPartitionPathRDDPartitioner — new, @ParameterizedTest over the same parallelisms, asserting Objects.hash still overflows for the fixture.
  • TestCoalescingPartitioner#testShuffleSucceedsForMinValueHashKey — drives a real partitionBy over jsc so the failure is visible end to end rather than only through getPartition.

Reverting the source change turns them red with the exact index, e.g. key polygenelubricants at numPartitions 3 ==> expected: <1> but was: <-2>, and the shuffle test with ArrayIndexOutOfBoundsException: Index -2 out of bounds for length 3 from BypassMergeSortShuffleWriter, which indexes partitionWriters with the answer unguarded.

mvn test -pl hudi-client/hudi-spark-client -Dtest='*Partitioner*' — 59 tests, all passing. checkstyle:check clean.

Impact

No public API, config, or on-disk change.

An earlier version of this description claimed the two expressions agree except at Integer.MIN_VALUE, and that power-of-two parallelism routes unchanged. That is wrong, and thanks to @voonhous for catching it. Math.abs(h) % n and Math.floorMod(h, n) agree for every non-negative h, but for negative h they agree only when n divides h (or, for even n, when the remainder is exactly n/2). Measured over negative hashes:

numPartitions 1 2 3 4 5 7 8 16
negative hashes that reroute 0% 0% 66.7% 50% 80% 85.7% 75% 87.5%

So roughly half of all keys move to a different partition, at every parallelism above two — Math.abs(-1) % 8 is 1 where Math.floorMod(-1, 8) is 7.

That is a shuffle-placement change, not a correctness or compatibility one. Both partitioners are used only to route records within a single write job (SparkStreamingMetadataWriteHandler and the three bulk-insert repartition partitioners); the index is never persisted or compared across runs, and both expressions are deterministic functions of the hash, so records sharing a key or partition path still land together. Both expressions spread the full int domain evenly (measured: exactly one value in 2^32 comes out illegal under the old one), so the only behavioural difference is that a key hashing to Integer.MIN_VALUE now lands on a valid partition instead of failing the write.

Risk Level

low

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
…er.MIN_VALUE

CoalescingPartitioner and PartitionPathRDDPartitioner both derive the partition
as Math.abs(hash) % numPartitions. Math.abs leaves Integer.MIN_VALUE negative,
so the expression is negative whenever numPartitions does not divide 2^31, and
Partitioner#getPartition has to answer inside [0, numPartitions).

Use Math.floorMod, which agrees with the old expression for every hash the old
one handled correctly. BucketIndexUtil and JavaUpsertPartitioner already avoid
Math.abs the same way.

@voonhous voonhous left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fix looks correct and complete: the repo-wide sweep of extends Partitioner (Spark) and the Flink partitioners finds no other Math.abs(hash) % n site (HoodieTableMetadataUtil:960 uses the double-abs form, which is MIN_VALUE-safe). Inline comments cover the PR text and test strength.

Two things with no line to anchor on:

  • nit, optional: 197 of the last 200 master commits use conventional-commit titles, and pr_title_validation.yml labels [MINOR] the legacy format (still accepted). fix(spark): do not return a negative Spark partition when a hash is Integer.MIN_VALUE would match.
  • Unrelated, spotted while checking the floorMod precedent: TestBucketizedBloomCheckPartitioner.java:190 asserts 0 <= partition && partition <= 1000 for a 1000-partition partitioner; the upper bound should be < 1000. Worth a separate one-line PR rather than here.
@codecov-commenter

codecov-commenter commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.30%. Comparing base (efe02e1) to head (aabf146).
⚠️ Report is 14 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19776      +/-   ##
============================================
+ Coverage     78.11%   78.30%   +0.19%     
- Complexity    33673    33873     +200     
============================================
  Files          2540     2541       +1     
  Lines        141413   141708     +295     
  Branches      17123    17327     +204     
============================================
+ Hits         110467   110970     +503     
+ Misses        23250    23029     -221     
- Partials       7696     7709      +13     
Components Coverage Δ
hudi-common 83.65% <ø> (+0.07%) ⬆️
hudi-client 83.20% <100.00%> (+0.08%) ⬆️
hudi-flink 85.65% <ø> (-0.01%) ⬇️
hudi-spark-datasource 73.19% <ø> (+0.65%) ⬆️
hudi-utilities 74.54% <ø> (+0.04%) ⬆️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 70.71% <ø> (+0.61%) ⬆️
hudi-sync 75.54% <ø> (-0.10%) ⬇️
hudi-io 79.98% <ø> (+0.21%) ⬆️
hudi-timeline-service 84.23% <ø> (+0.78%) ⬆️
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.44% <0.00%> (-0.03%) ⬇️
flink-integration-tests 48.80% <ø> (-0.06%) ⬇️
hadoop-mr-java-client 44.07% <ø> (+0.06%) ⬆️
integration-tests 13.51% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 50.56% <100.00%> (+0.18%) ⬆️
spark-java-tests 52.20% <50.00%> (+0.07%) ⬆️
spark-scala-tests 46.84% <50.00%> (+0.10%) ⬆️
utilities 36.55% <0.00%> (+0.26%) ⬆️

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

Files with missing lines Coverage Δ
.../org/apache/hudi/client/CoalescingPartitioner.java 100.00% <100.00%> (ø)
...cution/bulkinsert/PartitionPathRDDPartitioner.java 100.00% <100.00%> (ø)

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

@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 replaces Math.abs(hash) % numPartitions with Math.floorMod(...) in CoalescingPartitioner and PartitionPathRDDPartitioner to prevent a negative Spark partition index when a hash is Integer.MIN_VALUE, and adds tests exercising that case. The fix looks correct; the substantive points around test coverage and sibling partitioners are already well covered in the existing inline comments from @voonhous. Please take a look at those inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. One minor naming nit on the new test class, otherwise the fix and tests are clean.

cc @yihua

@Test
void assertFixtureStillOverflowsToMinValue() {
assertEquals(Integer.MIN_VALUE, Objects.hash(MIN_VALUE_HASH_PATH));
}

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: the assert prefix reads like a shared assertion helper rather than a test case — could you rename it to something like fixtureHashOverflowsToMinValue or testFixtureOverflowsToMinValue to make its role as a @Test clear at a glance? Same thought applies to assertPartitionIsInRangeForMinValueHash on line 47.

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

@github-actions github-actions Bot added the size:S PR with lines of changes in (10, 100] label Sep 1, 2026
@PDGGK
PDGGK force-pushed the fix-negative-spark-partition branch from 34ffce9 to 7f64828 Compare September 1, 2026 05:53
Asserting only that the index is in range passes for abs-mod too. Spark's
HashPartitioner routes with Utils.nonNegativeMod, which is floorMod, so
comparing against it pins the exact index and is a real oracle.

The range-only assertions also let a wrong claim into the PR description:
abs-mod and floorMod differ for roughly half of all negative hashes at every
parallelism, not only for Integer.MIN_VALUE.
@PDGGK
PDGGK force-pushed the fix-negative-spark-partition branch from 7f64828 to aabf146 Compare September 1, 2026 05:59
@PDGGK

PDGGK commented Sep 1, 2026

Copy link
Copy Markdown
Author

You're right, sorry — I had the impact wrong, not just the wording. Reworded, and I moved the measured numbers into the Impact section rather than only fixing the two sentences, since the mistake was in what I thought the change did.

Both tests now assert equality with Spark's HashPartitioner alongside the range check, with your Integer.MIN_VALUE % 2^k == 0 comment so the parallelism list doesn't get trimmed later. Took the fixture-assertion nit too.

I did take the optional one. TestCoalescingPartitioner#testShuffleSucceedsForMinValueHashKey drives a partitionBy over a one-row RDD; on the old expression it fails with ArrayIndexOutOfBoundsException: Index -2 out of bounds for length 3, so the BypassMergeSortShuffleWriter path is pinned rather than just described.

@hudi-bot

hudi-bot commented Sep 1, 2026

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

@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 replaces Math.abs(hash) % numPartitions with Math.floorMod in CoalescingPartitioner and PartitionPathRDDPartitioner so getPartition always returns an index in [0, numPartitions), fixing the Integer.MIN_VALUE edge case, and adds tests that pin routing against Spark's HashPartitioner as the oracle. No new issues flagged from this automated pass beyond what prior rounds already raised — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

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

Labels

size:S PR with lines of changes in (10, 100]

5 participants