Skip to content

fix(hfile): bound compressed block decompression - #19930

Open
txwyy123 wants to merge 3 commits into
apache:masterfrom
txwyy123:codex/hudi-19929-native-hfile-decompression
Open

txwyy123 wants to merge 3 commits into
apache:masterfrom
txwyy123:codex/hudi-19929-native-hfile-decompression

Conversation

@txwyy123

@txwyy123 txwyy123 commented Sep 12, 2026 •

Copy link
Copy Markdown

Describe the issue this Pull Request addresses

The native HFile reader can fail with java.util.zip.ZipException: invalid block type or java.io.EOFException: Unexpected end of ZLIB input stream when trailing checksum bytes are passed to the GZIP decoder. This addresses #19929.

Summary and Changelog

  • Exclude trailing checksum bytes from native compressed HFile block decompression.
  • Limit decompression output to the block's uncompressed payload size, and document why the output buffer's checksum space must be excluded.
  • Adjust the existing hudi_1_0_hbase_2_4_9_512KB_GZ_20000.hfile test artifact so its first two data-block checksum tails resemble a gzip member with an invalid DEFLATE block type and one with truncated data. The existing TestHFileReader tests cover all 20,000 rows, point lookups, and prefix lookups without a new test class or test method.

Only those two 12-byte checksum regions are modified in the HBase-written fixture; headers, compressed payloads, block offsets, and the remaining file contents are unchanged. These deliberately substituted bytes are no longer valid CRCs. The native reader does not validate CRCs, and must exclude checksum bytes from decompression.

The reader uses onDiskDataSizeWithHeader - HFILEBLOCK_HEADER_SIZE for decoder input and uncompressedSizeWithoutHeader for the output limit. onDiskSizeWithoutHeader includes trailing checksum bytes, and the output buffer also reserves checksum space; requesting that extra output space makes readFully continue beyond the first gzip stream.

Validation on Aircompressor 2.0.3:

  • Full hudi-io test suite: 126 tests passed, 0 failures, 0 errors, 0 skipped. Checkstyle passed.
  • The existing reader tests pass with the adjusted fixture and the fix.
  • Restoring both pre-fix decompression bounds makes the existing reader test fail with ZipException: invalid block type. Temporarily restoring only the first block's original checksum exposes the second block's EOFException: Unexpected end of ZLIB input stream. Both modified tails and the fix were restored before the full-suite run.

Impact

No public API, storage format, writer serialization, metadata logic, or block navigation changes. The fix only narrows the native reader's decompression input and output bounds.

Risk Level

low. The change is limited to decompression boundaries and is covered by the existing comprehensive reader tests using the adjusted fixture and the full hudi-io test suite.

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

Exclude trailing checksum bytes from the native GZIP input stream and add a regression test for Hudi issue apache#19929.
@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Sep 12, 2026

@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 bounds native HFile block decompression to the compressed payload (using onDiskDataSizeWithHeader from the block header) so trailing checksum bytes are no longer fed to the gzip decoder, and limits the output to uncompressedSizeWithoutHeader. I traced the writer (serialize), the airlift decompressor / readFully semantics, and the buffer sizing, and the new bounds are consistent with both the Hudi writer and the HBase v3 header layout. No issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@danny0405

Copy link
Copy Markdown
Contributor

cc @linliu-code for the code reviewing.

@codecov-commenter

codecov-commenter commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.36%. Comparing base (1f5a4fe) to head (c252833).
⚠️ Report is 57 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19930      +/-   ##
============================================
+ Coverage     80.23%   80.36%   +0.13%     
- Complexity    34747    34792      +45     
============================================
  Files          2546     2545       -1     
  Lines        142608   142706      +98     
  Branches      17362    17671     +309     
============================================
+ Hits         114419   114689     +270     
+ Misses        20277    20109     -168     
+ Partials       7912     7908       -4     
Components Coverage Δ
hudi-common 83.90% <ø> (+0.04%) ⬆️
hudi-client 83.41% <ø> (+0.08%) ⬆️
hudi-flink 85.68% <ø> (+0.11%) ⬆️
hudi-spark-datasource 73.79% <ø> (+0.53%) ⬆️
hudi-utilities 78.18% <ø> (-0.02%) ⬇️
hudi-cli 69.99% <ø> (ø)
hudi-hadoop 70.96% <ø> (+0.07%) ⬆️
hudi-sync 76.02% <ø> (ø)
hudi-io 81.51% <100.00%> (-0.11%) ⬇️
hudi-timeline-service 83.34% <ø> (ø)
hudi-cloud 81.00% <ø> (+<0.01%) ⬆️
hudi-kafka-connect 53.96% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 52.26% <100.00%> (+0.21%) ⬆️
flink-integration-tests 49.42% <100.00%> (+0.37%) ⬆️
hadoop-mr-java-client 43.86% <100.00%> (-0.05%) ⬇️
integration-tests 13.46% <100.00%> (+<0.01%) ⬆️
spark-client-hadoop-common 38.57% <100.00%> (+0.04%) ⬆️
spark-java-tests 52.37% <100.00%> (+0.14%) ⬆️
spark-scala-tests 46.94% <100.00%> (+0.03%) ⬆️
utilities 36.82% <100.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/io/hfile/HFileBlock.java 94.23% <100.00%> (+0.17%) ⬆️

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

@txwyy123

Copy link
Copy Markdown
Author

@linliu-code Gentle follow-up on this PR. The latest Azure CI and Codecov checks are green, and the modified lines are fully covered. The HFile regression test and full hudi-io test suite also pass locally. When you have a chance, could you please take a look? Happy to address any feedback. Thanks!

@linliu-code linliu-code left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for working on this! The PR limits native HFile block decompression to the compressed payload and the uncompressed size, so checksum bytes can no longer be read as a second gzip stream. I checked locally that the new test fails with invalid block type on the pre-fix code with the current Aircompressor 2.0.3, so the description's note that 2.0.3 can hide the over-read may not be needed. The full hudi-io suite passes, including the HBase-written GZ fixtures. Two small inline comments, mainly a suggestion to make the test cover the HBase-written case and the EOFException variant. This is one review pass, not an approval.

};

@Test
void compressedBlockDoesNotDecodeTrailingChecksums() throws IOException {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would you consider generalizing this into a property: for an HBase-written GZ fixture with real CRC32C checksums (e.g. hudi_1_0_hbase_2_4_9_512KB_GZ_20000.hfile, whose first block has 12 checksum bytes), overwrite the checksum bytes and assert all rows read back identically to the unmodified file? Parameterized over real CRCs, zeros, a gzip header + 07, a gzip header + truncated data, and ~20 seeded random tails starting with 1f 8b 08. I tried it locally: all pass with this change, while the pre-fix code fails on the two header cases and ~6% of the random tails, mostly with EOFException: Unexpected end of ZLIB input stream, which the current test doesn't reach.

// Copy the block header which is not compressed
System.arraycopy(
compressedByteBuff, startOffsetInCompressedBuff, byteBuff, 0, HFILEBLOCK_HEADER_SIZE);
// onDiskSizeWithoutHeader includes the trailing checksum bytes, while

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could the comment also mention the output side? byteBuff is sized with sizeCheckSum included, so asking for byteBuff.length - HFILEBLOCK_HEADER_SIZE made readFully keep reading past the end of the first gzip stream. Worth noting so both limits stay.

@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! The PR narrows the native HFile reader's GZIP decompression to the block's compressed payload (onDiskDataSizeWithHeader) and caps output at uncompressedSizeWithoutHeader, with a parameterized regression test over an HBase-written GZ fixture. The bounds line up with what the writer side in the same class emits; the main thing worth double-checking is the unvalidated decompress return value, which can now silently zero-fill a block instead of erroring. 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.

try (InputStream byteBuffInputStream = new ByteArrayInputStream(
compressedByteBuff, startOffsetInCompressedBuff + HFILEBLOCK_HEADER_SIZE, onDiskSizeWithoutHeader)) {
compressedByteBuff, startOffsetInCompressedBuff + HFILEBLOCK_HEADER_SIZE, compressedDataSize)) {
context.getCompressor().decompress(

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.

🤖 Now that the output limit is exactly uncompressedSizeWithoutHeader, would it be worth checking the returned count? IOUtils.readFully breaks silently on read() == -1, so a block whose gzip payload decodes to fewer bytes than the header claims would leave the tail of byteBuff zero-filled and be parsed as key-values instead of failing loudly. Something like if (decompressed != uncompressedSizeWithoutHeader) throw new IOException(...) would turn that into a clear error.

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

// byteBuff also reserves sizeCheckSum bytes, so limit output to uncompressedSizeWithoutHeader.
// Using byteBuff.length - HFILEBLOCK_HEADER_SIZE makes readFully keep reading beyond
// the first gzip stream to fill that checksum space.
int compressedDataSize = onDiskDataSizeWithHeader - HFILEBLOCK_HEADER_SIZE;

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.

🤖 Is onDiskDataSizeWithHeader guaranteed sane for every HFile the native reader accepts? If a block ever carries 0 (or a value larger than the bytes actually present), compressedDataSize goes negative / out of range and ByteArrayInputStream throws an unchecked IndexOutOfBoundsException instead of an IOException. A bounds check with a descriptive IOException might make corrupt blocks easier to diagnose.

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

byteBuff, startOffsetInBuff + Header.ON_DISK_DATA_SIZE_WITH_HEADER_INDEX);
this.uncompressedSizeWithoutHeader = readInt(
byteBuff, startOffsetInBuff + Header.UNCOMPRESSED_SIZE_WITHOUT_HEADER_INDEX);
this.bytesPerChecksum = readInt(

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.

🤖 [Line 125] Slightly related: now that the real checksum length is derivable as onDiskSizeWithoutHeader + HFILEBLOCK_HEADER_SIZE - onDiskDataSizeWithHeader (exactly what the new test computes), sizeCheckSum here is computed from getOnDiskSizeWithHeader(), which already includes the checksum bytes and so can over-count chunks. Harmless today since it only over-allocates byteBuff, but would it be cleaner to derive it from onDiskDataSizeWithHeader?

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

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

/** Regression tests for compressed HFile block boundaries. */
class TestHFileBlockDecompression {

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.

Instead of adding a new test class and new tests, could you adjust existing test artifacts that are used for comprehensive reader testing?

@github-actions github-actions Bot added size:S PR with lines of changes in (10, 100] and removed size:M PR with lines of changes in (100, 300] labels Sep 24, 2026
@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

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:S PR with lines of changes in (10, 100]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants