Skip to content

[DNM, stacked] fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests#19083

Open
yihua wants to merge 13 commits into
apache:masterfrom
yihua:hfile-fix-multiblock-prev-offset
Open

[DNM, stacked] fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests#19083
yihua wants to merge 13 commits into
apache:masterfrom
yihua:hfile-fix-multiblock-prev-offset

Conversation

@yihua

@yihua yihua commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Stacked on #19071 (please review that first). Three follow-ups on top of it that bring the native HFile writer in line with the internal mirror:

  1. Fix: HFileWriterImpl wrote each data block's previous-block-offset header as the block's own offset instead of the previous block's (passed currentOffset rather than lastDataBlockOffset). The first block is -1, so single-block golden tests never caught it. The native reader is forward-only and ignores the field, but the HBase reader's HFileScanner.seekBefore() reads it and throws IllegalStateException at every block boundary on a native-written multi-block file.
  2. Refactor: place each variable-length integer encoder on the block class whose framing it defines (HFileIndexBlock.getVarIntBytes for the Hadoop WritableUtils VInt index keyLength, HFileFileInfoBlock.getProtobufVarIntBytes for the protobuf-delimited trailer / file-info length prefixes), replacing the ambiguously named getVariableLengthEncodedBytes.
  3. Tests: byte-level validation of every HFile section, plus the regression tests for the fix.

Summary and Changelog

  • HFileWriterImpl: pass lastDataBlockOffset (not currentOffset) when starting the next data block.
  • HFileIndexBlock / HFileFileInfoBlock / HFileBlock / IOUtils / HFileRootIndexBlock / HFileMetaIndexBlock: move the two varint encoders onto their block classes; remove HFileBlock.getVariableLengthEncodedBytes and IOUtils.writeVarInt.
  • TestHFileWriter: byte-level layout validation of every section (data, meta, root data index, meta index, file info, trailer); golden + HBase byte-parity for single-block files across single-byte, two-byte and three-byte index keyLengths (116, 500, 2000-char first keys); the data-block previous-block-offset chain check; and VInt encoder unit tests.
  • TestHFileReadCompatibility: hbaseReaderSeekBeforeWorksOnNativeMultiBlockFile, which reads a native-written multi-block file with the HBase reader and asserts seekBefore(key(i)) returns key(i - 1).
  • hfile_format.md: name the index keyLength encoding (Hadoop WritableUtils VInt) and distinguish it from the protobuf-delimited trailer / file-info length prefixes.

Impact

Storage-format / cross-reader compatibility: an HBase reader can now seekBefore (reverse positioning) over native-written multi-block HFiles. No public API change; no change to single-block files or to forward reads / point lookups.

Risk Level

low

The prev-offset fix only changes an 8-byte header that the native reader ignores. The encoder move is a pure refactor with no change to on-disk bytes, verified by the golden + HBase byte-parity tests. The new regression tests were confirmed to fail on the pre-fix writer, and the full hudi-io test suite passes (111 tests).

Documentation Update

The in-repo hfile_format.md spec is updated in this PR. No website change.

Contributor's checklist

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

yihua added 11 commits June 25, 2026 10:34
…ve HFile writer

The native hudi-io HFile writer stored each block-index first key as a bare
[2-byte len][row] with no HBase KeyValue suffix. An HBase-based HFile reader
parsed it as a full KeyValue and read the family-length byte one past the end,
throwing ArrayIndexOutOfBoundsException in KeyValue.getFamilyLength during a
point or prefix lookup of a data-block boundary key. Full scans worked; only the
block-index binary search crashed.

Write each block-index first key as a full HBase KeyValue key
([2-byte rowLen][row][cfLen=0][ts=LATEST][type=Put]), matching the data-block
entries. The native reader is unaffected: it compares only the row via the 2-byte
length prefix, so it reads both old bare-key and new full-key files. Only files
written after this change are affected.

Tests (hudi-io):
- TestHFileReadCompatibility: an HBase reader point-looks-up every key, including
  block-boundary keys, in a native-written multi-block file; native-vs-HBase
  writer cells are byte-identical under the HBase reader; and a native file reads
  identically through the native and HBase readers. The point-lookup and
  byte-comparison cases run under both NONE and GZIP compression.
- TestHFileWriter: a golden byte comparison of the data block and root block-index
  block locks the on-disk format; the single index entry grows by the 10-byte
  KeyValue suffix.
Both the data block and the root index block now serialize the HBase KeyValue
key via HFileUtils.writeKeyValueKey / keyValueKeyLength, so the column-family
length (0), timestamp (latest), and key type (Put) are single-sourced and cannot
drift between the two blocks (a point lookup compares index keys against data
keys, so a mismatch would break it). Behavior-preserving: the on-disk bytes are
unchanged and the byte-exact format-lock golden still passes.
Validate the exact serialized bytes of each data-block record and each
root-index entry field by field (key and value lengths, row, column-family
length, timestamp, key type, and MVCC), pinning the HBase KeyValue framing
at the block level.
…lock base class

Move keyValueKeyLength and writeKey (renamed from writeKeyValueKey) out of
HFileUtils and into the shared HFileBlock base class, next to the existing
getVariableLengthEncodedBytes helper, so the data block and the root index
block share them without a util class. The timestamp, key type, and suffix
length constants are now private to HFileBlock. Restore the original data
block comment and drop the redundant inline comment in the root index block;
the rationale now lives in the keyValueKeyLength javadoc.
- Refer to the field as the KeyValue key (file-format-specific, not HBase
  specific) in HFileBlock javadocs and comments.
- writeKey now takes the row length explicitly rather than the backing-array
  length, since a Key's array can be larger than its content.
- Document the KeyValue key metadata suffix (column-family length, timestamp,
  key type) in hfile_format.md for the data block and the data index entry.
writeKey now takes the row offset as well as the length and writes
row[offset, offset + length), so a key that is a view into a larger buffer
(non-zero offset) serializes correctly. The root index caller passes the
key's offset; the data block passes 0. No behavior change for current callers
(write-path keys start at offset 0), but it removes the offset-0 assumption.
Describe what the block-index point-lookup and midKey tests validate (the
HFile key encoding) rather than referencing before/after behavior.
…fied in the read-compat test

Switch the assertion calls to individually static-imported methods (matching the
other hudi-io tests) and, since this file is in the org.apache.hudi.io.hfile
package, reference the native HFileContext unqualified while fully-qualifying the
HBase HFileContext.
…format-lock test

Write the fixed records with the HBase HFile writer (NONE compression, NULL
checksum, latest timestamp, Put type) and assert its data block and root
block-index bytes equal the same golden, confirming the native and HBase writers
produce byte-identical blocks.
…i-block HFiles

The native HFile writer set each data block's previous-block-offset header to the block's
own offset instead of the previous block's: flushCurrentDataBlock passed currentOffset (the
next block's start) instead of lastDataBlockOffset when starting the next block. The first
block is -1, so single-block golden tests never caught it.

The native reader is forward-only and never reads this field, so it is latent for Hudi, but
the HBase reader uses it in HFileScanner.seekBefore() to step back to the prior block: on a
native-written multi-block file it re-reads the same block and throws IllegalStateException
at every block boundary. Adds a regression test that reads such a file with the HBase reader
and asserts seekBefore(key(i)) returns key(i-1) for every record.
@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Jun 28, 2026
yihua added 2 commits June 27, 2026 17:15
…ock class

Move the Hadoop WritableUtils VInt encoder to HFileIndexBlock.getVarIntBytes (used by the
root and meta index key lengths) and the Protobuf varint encoder to
HFileFileInfoBlock.getProtobufVarIntBytes (used by the file info and trailer length
prefixes), instead of the generic IOUtils / HFileBlock. The two encodings are byte-identical
for 0..127 and diverge at >= 128, so the ambiguously named getVariableLengthEncodedBytes is
replaced and each method's javadoc cross-references the other. The format spec now names the
index keyLength encoding and distinguishes it from the protobuf-delimited trailer/file-info
length prefixes.
…er tests

Validate the raw on-disk bytes of every HFile section (data, meta, root data index, meta
index, file info, trailer) on a multi-block file; pin single-block data/root-index bytes to
a golden the HBase writer must reproduce across key lengths (single-byte through three-byte
index keyLength, up to 2000-char keys); assert the index keyLength is Hadoop WritableUtils
VInt and never protobuf varint; check the data block previous-block-offset chain; and
unit-test the VInt encoder.
@yihua yihua changed the title fix(storage-format): write the correct previous-block offset for multi-block HFiles fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests Jun 28, 2026
@yihua yihua changed the title fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests [DNM] fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests Jun 28, 2026
@yihua yihua changed the title [DNM] fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests [DNM, stacked] fix(storage-format): fix multi-block previous-block offset and add byte-level HFile writer tests Jun 28, 2026
@github-actions github-actions Bot added size:XL PR with lines of changes > 1000 and removed size:L PR with lines of changes in (300, 1000] labels Jun 28, 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

@voonhous voonhous self-requested a review July 2, 2026 09:45

@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.

Reviewed the 3-commit delta only (the base commits were reviewed on #19071.

Verified the fix end to end: traced lastDataBlockOffset through flush/close (first block -1, each block pointing at the prior one, header offset 16), confirmed the native reader never reads prev-offset, and confirmed hudi-io main has no hadoop dependency (so the hand-rolled VInt encoder is justified rather than delegating to WritableUtils).

@@ -150,42 +150,6 @@ public static boolean isNegativeVarLong(byte value) {
return value < -120 || (value >= -112 && value < 0);
}

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.

writeVarInt shipped as public API in release-1.2.0 (IOUtils is public in the published hudi-io artifact) and the replacement HFileIndexBlock.getVarIntBytes is package-private, so this is a binary-incompatible removal with no deprecation cycle. Suggest keeping a deprecated public delegate here for one release, or calling the removal out explicitly in the PR/release notes if hudi-io internals are considered exempt from API guarantees.

assertTrue(hbaseReader.getTrailer().getDataIndexCount() > 1,
"precondition: the file must have multiple data blocks for seekBefore to cross boundaries");
HFileScanner scanner = hbaseReader.getScanner(true, true);
for (int i = 1; i < MULTI_BLOCK_RECORDS; i++) {

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 loop starts at i=1, so seekBefore(key(0)) -- which must return false via the first block's -1 prev-offset sentinel -- is never exercised through the HBase reader. One assertFalse(scanner.seekBefore(...)) on key(0) before the loop covers the sentinel path end to end.

// 4. Create a new data block.
currentDataBlock = HFileDataBlock.createDataBlockToWrite(context, currentOffset);
// 4. Create a new data block whose previous-block offset points at the block just flushed.
currentDataBlock = HFileDataBlock.createDataBlockToWrite(context, lastDataBlockOffset);

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 fix is correct, but the offset is still threaded through a constructor arg at two call sites -- the exact shape that produced the bug. HFileBlock already stores previousBlockOffsetForWrite and the writer already stamps other blocks via setStartOffsetInBuffForWrite; stamping the prev-offset with a setter at flush time from one field (and dropping the constructor param) would make a wrong-variable regression unrepresentable rather than merely tested.

currentDataBlock.getFirstKey(), lastDataBlockOffset, blockBuffer.limit());
// 4. Create a new data block.
currentDataBlock = HFileDataBlock.createDataBlockToWrite(context, currentOffset);
// 4. Create a new data block whose previous-block offset points at the block just flushed.

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.

Worth a one-line note (here or in hfile_format.md) that only DATA blocks chain prev-offsets: root/meta index, file info, and meta blocks still write -1, while HBase's writer chains every block type. Benign today -- HBase's reader only consults prev-offset for data blocks in seekBefore -- but the intentional divergence is currently undocumented.

* @return the Protobuf varint encoding.
* @throws IOException upon error.
*/
static byte[] getProtobufVarIntBytes(int value) throws IOException {

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.

This helper frames two unrelated sections -- file info AND the trailer (HFileWriterImpl statically imports it, and HFileTrailer owns the read counterpart parseDelimitedFrom) -- so HFileFileInfoBlock is an odd owner. HFileUtils (already home to readMajorVersion) fits better, and gives a consistent convention: hierarchy-scoped encoders on the block hierarchy (getVarIntBytes on HFileIndexBlock is right), cross-section framing in HFileUtils. The same convention would resolve the writeKey/keyValueKeyLength placement comment from the #19071 review.

kv.getValueOffset(),
kv.getValueOffset() + kv.getValueLength())
);
assertEquals(keys[i], reader.getKeyValue().get().getKey().getContentInString());

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 deleted testLongKeys also asserted value bytes on this file shape (multi-block, multi-byte-vint index keys); this loop only checks key content. Asserting the value per record restores that coverage in one line.

for (int i = 0; i < numRecords; i++) {
String key = longPrefix + String.format("%04d", i);
writer.append(key, String.format("value%04d", i).getBytes());
void writerBlockBytesAreStableFormatLock() throws Exception {

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.

Three structure nits: (1) four single-block scenarios plus the multi-block validation run in one @test, so the first failing golden masks the rest -- a @ParameterizedTest over {keys, values, goldens} isolates failures; (2) the direct calls pass dead null, null golden args that only the wrapper ever fills -- moving the golden assert up into the wrapper removes the null plumbing; (3) the helper javadoc cites a 'per-test assertion limit' that doesn't exist in JUnit or this repo's checkstyle -- reword to the real reason (readability).

return s.getBytes(StandardCharsets.UTF_8);
}

private static long readLongBE(byte[] b, int off) {

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.

New helpers in this file re-implement existing utilities: readLongBE == IOUtils.readLong (public, already imported here); keyContentString == new Key(bytes).getContentInString() (same package); bytes/utf8 == StringUtils.getUTF8Bytes/fromUTF8Bytes; makeKey hand-rolls a fill loop next to the existing rep(). Same theme as the #19071 comment on indexOf/readIntBE/hex -- worth one sweep.


@Slf4j
class TestHFileWriter {
private static final Logger LOG = LoggerFactory.getLogger(TestHFileWriter.class);

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.

Is the @slf4j -> manual logger swap deliberate (internal-mirror alignment?) -- hudi-io tests still use lombok elsewhere (TestStoragePathInfo @slf4j, TestHFileReader @Getter). If not, keeping @slf4j drops three lines.

Comment thread hudi-io/hfile_format.md
size.
- **Key Length**: [variable-length encoded](https://en.wikipedia.org/wiki/Variable-length_quantity) number representing
the length of the "Key" part.
- **Key Length**: the length of the "Key" part, encoded as a Hadoop `WritableUtils` variable-length integer (VInt). A

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: the byte-identical-for-0..127 / diverge-at->=128 rule now appears four times (here, both encoder javadocs, the test javadoc). Suggest keeping this doc as the canonical statement and reducing the javadocs to one-line pointers so the copies can't drift.

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

Labels

size:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants