Skip to content

[MINOR] Decode log block header/footer metadata as UTF-8 - #19454

Open
linliu-code wants to merge 1 commit into
apache:masterfrom
linliu-code:lin/logblock-header-metadata-utf8
Open

[MINOR] Decode log block header/footer metadata as UTF-8#19454
linliu-code wants to merge 1 commit into
apache:masterfrom
linliu-code:lin/logblock-header-metadata-utf8

Conversation

@linliu-code

@linliu-code linliu-code commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

HoodieLogBlock.getLogMetadata() read each metadata entry with new String(byte[]), which applies the platform default charset. The write side, getLogMetadataBytes(), serializes those same values with StringUtils.getUTF8Bytes(). The two sides therefore disagree on any JVM whose default charset is not UTF-8.

The visible consequence is a corrupted header/footer value on read. When that value is the writer schema and it contains non-ASCII field names, the corrupted string then fails Avro parsing with Illegal initial character.

StringUtils.fromUTF8Bytes already lives in the same class this file imports getUTF8Bytes from, so no new dependency is introduced.

Also adds a round-trip test asserting that a schema containing a non-ASCII field name survives getHeaderMetadataBytesgetHeaderMetadata and still parses as Avro.

Summary and Changelog

// before
metadata.put(typeMapper.apply(metadataEntryIndex), new String(metadataEntry));
// after
metadata.put(typeMapper.apply(metadataEntryIndex), fromUTF8Bytes(metadataEntry));

Impact

Read path only, for log block header/footer metadata.

Correct on any JVM whose default charset is already UTF-8 — behaviour there is unchanged. On a JVM with a non-UTF-8 default charset, non-ASCII header values now round-trip instead of being corrupted.

One case worth calling out explicitly: if a table's log blocks were written by a JVM whose default charset was not UTF-8, those header bytes are not valid UTF-8 on disk, and this change makes them decode differently than before. That data was already being written inconsistently with getLogMetadataBytes()'s contract, so I believe aligning the read with the documented write encoding is correct — but I'd appreciate a reviewer's view on whether any compatibility shim is wanted.

Risk Level

Low risk.

Two-line change on the read side, plus a test.

Verified against the old code. The test fails before the change and passes after, when run with a non-UTF-8 default charset:

# unfixed, LC_ALL=C
AssertionFailedError: non-ASCII header value must round-trip via UTF-8 regardless of the JVM default charset
  expected: <{"type":"record","name":"r","fields":[{"name":"名字",...}]}>
  but was:  <{"type":"record","name":"r","fields":[{"name":"?????\u003f",...}]}>   # 2 chars -> 6
# fixed, LC_ALL=C
Tests run: 1, Failures: 0, Errors: 0

The two characters decode as six, i.e. the six UTF-8 bytes are read as six single-byte characters. On a JVM whose default charset is already UTF-8 the test passes either way, so reproducing requires LC_ALL=C (or -Dfile.encoding=ISO-8859-1).

Verified: hudi-common compiles, and the new test passes (Tests run: 1, Failures: 0, Errors: 0).

Documentation Update

None needed.

Contributor's checklist

  • Read through contributor's guide
  • Change Logs and Impact were stated clearly
  • Adequate tests were added if applicable
  • CI passed

HoodieLogBlock.getLogMetadata() read each metadata entry with new String(byte[]),
which applies the platform default charset. The write side
(getLogMetadataBytes) serializes those values with StringUtils.getUTF8Bytes(),
so the two sides disagree on any JVM whose default charset is not UTF-8.

The visible consequence is a corrupted header value on read. When the value is
the writer schema and it contains non-ASCII field names, the corrupted string
then fails Avro parsing with "Illegal initial character".

Decode with StringUtils.fromUTF8Bytes() so the read matches the write. That
method already lives in the same StringUtils class this file imports
getUTF8Bytes from, so no new dependency is introduced.

Adds a round-trip test asserting a schema with a non-ASCII field name survives
getHeaderMetadataBytes -> getHeaderMetadata and still parses as Avro.

Note on the test's scope: the old and new code agree whenever the JVM default
charset is already UTF-8, so this test passes before and after the change on a
UTF-8 JVM. It documents the invariant rather than reproducing the failure;
reproducing that requires running with a non-UTF-8 default charset, e.g.
-Dfile.encoding=ISO-8859-1, under which new String(utf8Bytes) demonstrably
mojibakes.
@codecov-commenter

codecov-commenter commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.72%. Comparing base (9d32b3d) to head (a7dab3f).

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19454      +/-   ##
============================================
+ Coverage     76.58%   76.72%   +0.13%     
- Complexity    33595    33722     +127     
============================================
  Files          2576     2576              
  Lines        143560   143560              
  Branches      17607    17607              
============================================
+ Hits         109949   110142     +193     
+ Misses        25403    25167     -236     
- Partials       8208     8251      +43     
Components Coverage Δ
hudi-common 82.26% <100.00%> (-0.01%) ⬇️
hudi-client 81.83% <ø> (+<0.01%) ⬆️
hudi-flink 83.97% <ø> (+<0.01%) ⬆️
hudi-spark-datasource 73.74% <ø> (+0.75%) ⬆️
hudi-utilities 73.64% <ø> (-0.02%) ⬇️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.49% <ø> (ø)
hudi-sync 70.90% <ø> (+0.02%) ⬆️
hudi-io 79.60% <ø> (ø)
hudi-timeline-service 83.54% <ø> (ø)
hudi-cloud 64.00% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (-0.22%) ⬇️
Flag Coverage Δ
common-and-other-modules 49.49% <100.00%> (-0.01%) ⬇️
flink-integration-tests 48.80% <100.00%> (-0.01%) ⬇️
hadoop-mr-java-client 43.34% <100.00%> (-0.06%) ⬇️
integration-tests 13.57% <0.00%> (+<0.01%) ⬆️
spark-client-hadoop-common 48.68% <100.00%> (ø)
spark-java-tests 51.30% <100.00%> (+0.01%) ⬆️
spark-scala-tests 47.13% <100.00%> (+0.15%) ⬆️
utilities 36.56% <100.00%> (-0.01%) ⬇️

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

Files with missing lines Coverage Δ
...he/hudi/common/table/log/block/HoodieLogBlock.java 77.37% <100.00%> (ø)

... and 12 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 fixes a charset mismatch by decoding log block header/footer metadata as UTF-8 on the read side to match the write side (getLogMetadataBytes()), which uses StringUtils.getUTF8Bytes(). No issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

@github-actions github-actions Bot added the size:S PR with lines of changes in (10, 100] label Aug 1, 2026
@hudi-bot

hudi-bot commented Aug 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

@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 change and its history. Correct fix; the inline comments are all nits, nothing blocking.

Confirmed

  • This completes #9634 / HUDI-6825 (Sep 2023), which moved the write side from entry.getValue().getBytes() to getUTF8Bytes(...) but left the read at the platform default charset. Before that commit both sides used the default charset and were at least self-consistent. Worth citing #9634 in the PR description.
  • The method's own javadoc already documents the contract ("Write the actual bytes of the value string in UTF-8 encoding"), so the read now matches it.
  • getLogMetadata is the only header/footer decode site in the repo -- no parallel decoder in the native log reader or the HFile/Parquet block paths, and the footer path shares this same method.
  • Framing was never affected: the length prefix is written from the UTF-8 bytes.length, so only the decode was wrong.
  • No new failure mode: new String(bytes, UTF_8) substitutes U+FFFD on malformed input rather than throwing, so a legacy/garbage header cannot newly raise a decoding exception.

One correction to the PR description

if a table's log blocks were written by a JVM whose default charset was not UTF-8, those header bytes are not valid UTF-8 on disk

That case cannot happen for any writer since 1.0.0 -- getUTF8Bytes is str.getBytes(StandardCharsets.UTF_8), unconditional, regardless of JVM charset. The real (much narrower) case is a table written by Hudi <= 0.14.x on a non-UTF-8 JVM. Even there, the old read was only correct when the reader's default charset happened to match the writer's, which was never guaranteed -- so this is no worse in expectation. No shim needed, but please fix the wording so reviewers are not evaluating a scenario that cannot occur.

Scope of the bug

Per JEP 400 the default charset is UTF-8 on JDK 18+, so this only bites JDK 8/11/17 with a non-UTF-8 locale (Windows-1252, or unset LANG / LC_ALL=C in containers). Corollary: the new test cannot fail on JDK 18+, and in CI its value depends on the container locale. Fine for a MINOR -- just do not count on it as a hard regression guard.

Nit that is not a problem

The raw 名字 literal in the test source is fine as-is: the root pom sets project.build.sourceEncoding=UTF-8 and there is precedent (TestHoodieSchema, TestHoodieAvroUtils). No need for \uXXXX escapes -- checkstyle's AvoidEscapedUnicodeCharacters would push back on those anyway.

Follow-up, out of scope here

HoodieTableMetaClient.java:358 has the same bug class: HoodieIndexMetadata.fromJson(new String(bytesOpt.get())) decodes index metadata read from storage with the platform default charset. Worth a separate MINOR.

Comment on lines +472 to +476
// Decode as UTF-8 to match the write side: getLogMetadataBytes() serializes these values
// with StringUtils.getUTF8Bytes(). Using new String(byte[]) here applies the platform
// default charset instead, so on any JVM whose default charset is not UTF-8 a non-ASCII
// header value (e.g. a writer schema containing non-ASCII field names) is corrupted on
// read, and downstream Avro parsing fails with "Illegal initial character".

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: 5 lines of comment for a 1-line change, and the javadoc ~40 lines above already documents the contract ("Write the actual bytes of the value string in UTF-8 encoding"). Suggest trimming to the essential pointer:

Suggested change
// Decode as UTF-8 to match the write side: getLogMetadataBytes() serializes these values
// with StringUtils.getUTF8Bytes(). Using new String(byte[]) here applies the platform
// default charset instead, so on any JVM whose default charset is not UTF-8 a non-ASCII
// header value (e.g. a writer schema containing non-ASCII field names) is corrupted on
// read, and downstream Avro parsing fails with "Illegal initial character".
// Must match getLogMetadataBytes(), which writes these values as UTF-8.

import org.apache.hudi.io.SeekableDataInputStream;
import org.apache.hudi.storage.HoodieStorage;

import org.apache.avro.Schema;

@voonhous voonhous Aug 1, 2026

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.

Tied to the comment below -- if you switch to HoodieSchema.parse, this import can go, we are trying to migrate away from avro.Schema.

Comment on lines +125 to +128
// The user-visible failure was Avro schema parsing, so assert the round-tripped schema parses
// and the non-ASCII field name survives.
Schema parsed = new Schema.Parser().parse(b.get(HoodieLogBlock.HeaderMetadataType.SCHEMA));
Assertions.assertEquals("名字", parsed.getFields().get(0).name());

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.

Prefer HoodieSchema.parse(...) over new Schema.Parser() here:

  • Every production consumer of the SCHEMA header parses that way (HoodieDataBlock:137, HoodieAvroDataBlock:111, HoodieParquetDataBlock:96), so this stays faithful to the actual downstream failure.
  • It is the dominant convention in hudi-common tests (206 uses vs 65 for Schema.Parser), and keeps the test on the Avro -> HoodieSchema migration path.
  • HoodieSchema is already imported; org.apache.avro.Schema then is not needed.
Suggested change
// The user-visible failure was Avro schema parsing, so assert the round-tripped schema parses
// and the non-ASCII field name survives.
Schema parsed = new Schema.Parser().parse(b.get(HoodieLogBlock.HeaderMetadataType.SCHEMA));
Assertions.assertEquals("名字", parsed.getFields().get(0).name());
// The user-visible failure was schema parsing, so assert the round-tripped schema parses
// and the non-ASCII field name survives.
HoodieSchema parsed = HoodieSchema.parse(b.get(HoodieLogBlock.HeaderMetadataType.SCHEMA));
Assertions.assertEquals("名字", parsed.getFields().get(0).name());

}

@Test
public void testHeaderMetadataWithNonAsciiSchema() 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.

Consider folding this into testHeaderMetadata above rather than adding a method -- one extra a.put(..., SCHEMA, <non-ascii schema>) plus one assertEquals gets the same coverage. Recent PMC feedback has been to extend existing tests instead of growing the method count.

The string-equality assert is the real check; the parse assert mostly exercises the schema parser. Your call though -- a separately named test does document the charset intent better.

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.

5 participants