Skip to content

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

Open
PDGGK wants to merge 1 commit 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 1 commit 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 and agrees with the old expression on every hash the old one already handled correctly — only the Integer.MIN_VALUE case changes, and there the old answer was not a usable partition index.

BucketIndexUtil ((partition.hashCode() & Integer.MAX_VALUE) % parallelism) and JavaUpsertPartitioner (Math.floorMod) already avoid Math.abs for the same reason.

Tests:

  • TestCoalescingPartitioner#testPartitionIsInRangeForMinValueHash — added to the existing class; asserts the fixture still hashes to Integer.MIN_VALUE first, so it cannot silently stop exercising the case, then checks the index is in range for 1..16 partitions.
  • TestPartitionPathRDDPartitioner — new, same shape, asserting Objects.hash still overflows for the fixture.

Reverting the change turns them red with partition -2 out of range for numPartitions 3 and the equivalent for 5, 6 and 7; the power-of-two parallelisms stay green either way, which is why this has not been hit before.

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

Impact

No public API or config change. Records whose key hashes to Integer.MIN_VALUE now land on a valid partition instead of failing the write; every other key routes exactly as before at power-of-two parallelism, and to a different but valid partition otherwise.

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.

return Math.abs(key.hashCode()) % numPartitions;
// Math.abs leaves Integer.MIN_VALUE negative, and a Partitioner must answer in
// [0, numPartitions). floorMod is non-negative for every input.
return Math.floorMod(key.hashCode(), numPartitions);

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.

The code is right, but the PR body and commit message claim floorMod "agrees with the old expression on every hash the old one already handled correctly" and that other keys "route exactly as before at power-of-two parallelism". Every negative hash reroutes at every parallelism: Math.abs(-1) % 3 == 1 vs Math.floorMod(-1, 3) == 2; Math.abs(-1) % 4 == 1 vs Math.floorMod(-1, 4) == 3.

Harmless here: the only caller (SparkStreamingMetadataWriteHandler:63) drops the partitioner with .map(entry -> entry._2), and floorMod is exactly Spark's HashPartitioner (Utils.nonNegativeMod), already used by UpsertPartitioner:358 and BucketizedBloomCheckPartitioner:176.

Please reword those two sentences to: negative hashes now route to a different but valid partition, matching Spark's HashPartitioner; positive hashes are unchanged. Keep floorMod.

return Math.abs(Objects.hash(partitionPathExtractor.apply(o))) % numPartitions;
// Math.abs leaves Integer.MIN_VALUE negative, and a Partitioner must answer in
// [0, numPartitions). floorMod is non-negative for every input.
return Math.floorMod(Objects.hash(partitionPathExtractor.apply(o)), numPartitions);

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.

Same routing change applies here for every negative Objects.hash, not just MIN_VALUE. Also fine: PartitionPathRepartitionPartitioner:66, PartitionPathRepartitionAndSortPartitioner:67 and LSMPartitionPathRepartitionAndSortPartitioner:75 all end in .values() and only rely on one partition path landing in one Spark partition, which any deterministic function keeps. Worth one sentence in the Impact section so the next reader does not have to re-derive it.

Comment on lines +197 to +201
for (int numPartitions : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 16}) {
int partition = new CoalescingPartitioner(numPartitions).getPartition(key);
assertTrue(partition >= 0 && partition < numPartitions,
"partition " + partition + " out of range for numPartitions " + numPartitions);
}

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.

Range-only leaves the routing unpinned: simpleCoalescingPartitionerTest uses Integer keys 0..100, so no test sees a negative hash, which is exactly where abs-mod and floorMod differ. Asserting equality with Spark's own HashPartitioner is a real oracle (it is Utils.nonNegativeMod, i.e. floorMod) and makes the "matches Spark" statement testable. Also, 1/2/4/8/16 cannot fail on the old code (Integer.MIN_VALUE % 2^k == 0, and 1 short-circuits before the modulo), so a comment keeps someone from trimming the list to powers of two.

Needs import org.apache.spark.HashPartitioner;.

Suggested change
for (int numPartitions : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 16}) {
int partition = new CoalescingPartitioner(numPartitions).getPartition(key);
assertTrue(partition >= 0 && partition < numPartitions,
"partition " + partition + " out of range for numPartitions " + numPartitions);
}
// Integer.MIN_VALUE % 2^k == 0, so only 3, 5, 6 and 7 fail on the old Math.abs expression.
for (int numPartitions : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 16}) {
int partition = new CoalescingPartitioner(numPartitions).getPartition(key);
assertTrue(partition >= 0 && partition < numPartitions,
"partition " + partition + " out of range for numPartitions " + numPartitions);
assertEquals(new HashPartitioner(numPartitions).getPartition(key), partition);
}

Comment on lines +48 to +50
int partition = partitioner.getPartition(new Object());
assertTrue(partition >= 0 && partition < numPartitions,
"partition " + partition + " out of range for numPartitions " + numPartitions);

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.

Same as the Coalescing test: pin the exact index against Spark's HashPartitioner rather than only the range. This partitioner hashes Objects.hash(path) (31 + path.hashCode()), not the string itself, so feed Spark the boxed int. Needs import org.apache.spark.HashPartitioner;.

Suggested change
int partition = partitioner.getPartition(new Object());
assertTrue(partition >= 0 && partition < numPartitions,
"partition " + partition + " out of range for numPartitions " + numPartitions);
int partition = partitioner.getPartition(new Object());
assertTrue(partition >= 0 && partition < numPartitions,
"partition " + partition + " out of range for numPartitions " + numPartitions);
assertEquals(new HashPartitioner(numPartitions).getPartition(Objects.hash(MIN_VALUE_HASH_PATH)), partition);

Comment on lines +38 to +46
@Test
void assertFixtureStillOverflowsToMinValue() {
assertEquals(Integer.MIN_VALUE, Objects.hash(MIN_VALUE_HASH_PATH));
}

@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 16})
void assertPartitionIsInRangeForMinValueHash(int numPartitions) {
PartitionPathRDDPartitioner partitioner =

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.

nit, feel free to ignore: TestCoalescingPartitioner asserts the fixture inline as the first line of the test. Doing the same here drops a method and the now-unused org.junit.jupiter.api.Test import (checkstyle will flag it if left behind).

Suggested change
@Test
void assertFixtureStillOverflowsToMinValue() {
assertEquals(Integer.MIN_VALUE, Objects.hash(MIN_VALUE_HASH_PATH));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 16})
void assertPartitionIsInRangeForMinValueHash(int numPartitions) {
PartitionPathRDDPartitioner partitioner =
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 16})
void assertPartitionIsInRangeForMinValueHash(int numPartitions) {
assertEquals(Integer.MIN_VALUE, Objects.hash(MIN_VALUE_HASH_PATH));
PartitionPathRDDPartitioner partitioner =

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class TestPartitionPathRDDPartitioner {

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.

Optional: every end-to-end harness for these sort modes uses power-of-two parallelism (TestBulkInsertInternalPartitioner:146 hardcodes 2, TestLSMBulkInsertPartitioner uses 1 and 4), and Integer.MIN_VALUE % 2^k == 0, so none of them could ever have caught this. If you want proof that Spark actually throws on the old code, one mapToPair(...).partitionBy(new PartitionPathRDDPartitioner(o -> "xfjfxsf", 3)) over a small RDD in a HoodieClientTestBase-derived test does it: BypassMergeSortShuffleWriter indexes partitionWriters with the result, unguarded. The getPartition asserts at 3/5/6/7 already discriminate, so not blocking on this.

@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 75.44%. Comparing base (efe02e1) to head (7e082e0).

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19776      +/-   ##
============================================
- Coverage     78.11%   75.44%   -2.67%     
+ Complexity    33673    32526    -1147     
============================================
  Files          2540     2540              
  Lines        141413   141413              
  Branches      17123    17123              
============================================
- Hits         110467   106695    -3772     
- Misses        23250    26853    +3603     
- Partials       7696     7865     +169     
Components Coverage Δ
hudi-common 81.97% <ø> (-1.61%) ⬇️
hudi-client 78.73% <100.00%> (-4.39%) ⬇️
hudi-flink 85.65% <ø> (-0.02%) ⬇️
hudi-spark-datasource 65.59% <ø> (-6.95%) ⬇️
hudi-utilities 74.51% <ø> (+0.01%) ⬆️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 67.14% <ø> (-2.96%) ⬇️
hudi-sync 75.56% <ø> (-0.08%) ⬇️
hudi-io 79.71% <ø> (-0.05%) ⬇️
hudi-timeline-service 77.57% <ø> (-5.88%) ⬇️
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.46% <0.00%> (-0.01%) ⬇️
flink-integration-tests 48.86% <ø> (+<0.01%) ⬆️
hadoop-mr-java-client 43.98% <ø> (-0.04%) ⬇️
spark-client-hadoop-common 50.38% <100.00%> (+<0.01%) ⬆️
spark-java-tests 32.29% <0.00%> (-19.84%) ⬇️
spark-scala-tests 46.74% <50.00%> (+<0.01%) ⬆️
utilities 36.30% <0.00%> (+0.01%) ⬆️

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 363 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-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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants