Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: Fix UnicodeUtil#truncateStringMax returns malformed string. #11161

Merged
merged 4 commits into from
Oct 7, 2024

Conversation

zhongyujiang
Copy link
Contributor

@zhongyujiang zhongyujiang commented Sep 18, 2024

We encountered an exception while writing data, and the stack trace is as follows. It occurred during the collection of Parquet column metrics:

Exception stack:

Suppressed: org.apache.iceberg.exceptions.RuntimeIOException: Failed to encode value as UTF-8: Ҋ�Qڞ<֔~�M�ECڮV?
at org.apache.iceberg.types.Conversions.toByteBuffer(Conversions.java:110)
at org.apache.iceberg.types.Conversions.toByteBuffer(Conversions.java:83)
at org.apache.iceberg.parquet.ParquetUtil.toBufferMap(ParquetUtil.java:343)
at org.apache.iceberg.parquet.ParquetUtil.footerMetrics(ParquetUtil.java:174)
at org.apache.iceberg.parquet.ParquetUtil.footerMetrics(ParquetUtil.java:86)
at org.apache.iceberg.parquet.ParquetWriter.metrics(ParquetWriter.java:166)
at org.apache.iceberg.io.DataWriter.close(DataWriter.java:100)
at org.apache.iceberg.io.RollingFileWriter.closeCurrentWriter(RollingFileWriter.java:122)
at org.apache.iceberg.io.RollingFileWriter.close(RollingFileWriter.java:147)
at org.apache.iceberg.io.RollingDataWriter.close(RollingDataWriter.java:32)
at org.apache.iceberg.io.FanoutWriter.closeWriters(FanoutWriter.java:82)
at org.apache.iceberg.io.FanoutWriter.close(FanoutWriter.java:74)
at org.apache.iceberg.io.FanoutDataWriter.close(FanoutDataWriter.java:31)
at org.apache.iceberg.spark.source.SparkWrite$PartitionedDataWriter.close(SparkWrite.java:1162)
at org.apache.spark.sql.execution.datasources.v2.DataWritingSparkTask$.$anonfun$run$9(WriteToDataSourceV2Exec.scala:423)
at org.apache.spark.util.Utils$.tryWithSafeFinallyAndFailureCallbacks(Utils.scala:1496)
... 10 more
Caused by: java.nio.charset.MalformedInputException: Input length = 1
at java.nio.charset.CoderResult.throwException(CoderResult.java:281)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:816)
at org.apache.iceberg.types.Conversions.toByteBuffer(Conversions.java:108)
... 25 more
Caused by: java.nio.charset.MalformedInputException: Input length = 1
at java.nio.charset.CoderResult.throwException(CoderResult.java:281)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:816)
at org.apache.iceberg.types.Conversions.toByteBuffer(Conversions.java:108)

Investigation

After some investigation, I found that when collecting Parquet column metrics, string metrics are truncated by default to a length of 16 characters. When truncating the max metric, if the truncated length is less than the length of the original max value, the last character will be incremented by 1 to ensure that the truncated value is greater than the max value. However, this increment operation does not consider skipping illegal UTF-8 Unicode code points, which may lead to the following exception.

In the scenario where we encountered this issue, there is a Parquet file with a column's max metric length exceeding 16, and the code point of its 16th character is '\uD7FF', which is Character.MIN_SURROGATE - 1. Adding 1 to this resulted in Character.MIN_SURROGATE, which is not a valid Unicode scalar value. Therefore, when Conversions.toByteBuffer attempted to encode it in UTF-8 format, a MalformedInputException was thrown.

This fix specifically skips illegal code points when incrementing the last character to avoid this issue.

To reproduce

CREATE TABLE my_table (data string) using iceberg;
INSERT INTO my_table VALUES('abcdefghigklmno\uD7FFp');

@zhongyujiang
Copy link
Contributor Author

@amogh-jahagirdar @nastra can you please help review this? thanks.

// surrogate code points are not Unicode scalar values,
// any UTF-8 byte sequence that would otherwise map to code points U+D800..U+DFFF is ill-formed.
// see https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G27288
Preconditions.checkArgument(
Copy link
Member

Choose a reason for hiding this comment

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

Just a minor point here, but shouldn't this only be relevant if we somehow get non-unicode binary in a unicode string? Shouldn't be possible in a Java string right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is possible for Java strings to contain only one unpaired surrogate character(non-unicode character), though encoding them using UTF-8 or UTF-16 will result in MalformedInputException. This is also the case in this issue, where the truncation method returns a string ending with an unpaired high-surrogate character, but fails when encoding it to UTF-8.

For a valid UTF-8 string, it will not contain unpaired surrogates. However, the codePointAt method may return a unpaired surrogate code point if an incorrect index is passed.

/**
* Returns the character (Unicode code point) at the specified
* index. The index refers to {@code char} values
* (Unicode code units) and ranges from {@code 0} to
* {@link #length()}{@code - 1}.
*
*

If the {@code char} value specified at the given index
* is in the high-surrogate range, the following index is less
* than the length of this sequence, and the
* {@code char} value at the following index is in the
* low-surrogate range, then the supplementary code point
* corresponding to this surrogate pair is returned. Otherwise,
* the {@code char} value at the given index is returned.
*
* @param index the index to the {@code char} values
* @return the code point value of the character at the
* {@code index}
* @throws IndexOutOfBoundsException if the {@code index}
* argument is negative or not less than the length of this
* sequence.
*/
public int codePointAt(int index) {

Currently, all methods in the UnicodeUtil class that use codePointAt are correct and will not result in an unpaired surrogate code point. I added it to strengthen the validation.

@@ -274,4 +274,17 @@ public void testTruncateStringMax() {
"Test input with multiple 4 byte UTF-8 character where the first unicode character should be incremented")
.isEqualTo(0);
}

@Test
public void testTruncateStringMaxUpperBound() {
Copy link
Member

Choose a reason for hiding this comment

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

Could we add these to the test above? I'm also fine if there is a specific reason to have them somewhere else but it seems like these would fit into the test above as just other examples. The test cases for +1 and MAX_CODE_POINT are there already right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved this to testTruncateStringMax

The test cases for +1 and MAX_CODE_POINT are there already right?
Yes, there is already one test case for MAX_CODE_POINT

Copy link
Member

@RussellSpitzer RussellSpitzer left a comment

Choose a reason for hiding this comment

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

I just have a few nits on this, but I this makes sense to me

Copy link
Contributor Author

@zhongyujiang zhongyujiang left a comment

Choose a reason for hiding this comment

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

@RussellSpitzer Thanks for reviewing, I've updated the PR to resolve your comments, please take a took when you have time.

@@ -202,11 +202,20 @@ public void testTruncateStringMax() {
String test5 = "\uDBFF\uDFFF\uDBFF\uDFFF";
String test6 = "\uD800\uDFFF\uD800\uDFFF";
// Increment the previous character
String test6_2_expected = "\uD801\uDC00";
String test6_1_expected = "\uD801\uDC00";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this should be a typo, "\uD800\uDFFF" is a Unicode surrogate pair, it's length is 1.

Copy link
Member

Choose a reason for hiding this comment

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

I'm confused on this one, why is this one changed? Why did the test pass before?

String test7 = "\uD83D\uDE02\uD83D\uDE02\uD83D\uDE02";
String test7_2_expected = "\uD83D\uDE02\uD83D\uDE03";
String test7_1_expected = "\uD83D\uDE03";

// Increment the max UTF-8 character will overflow
String test8 = "a\uDBFF\uDFFFc";
String test8_2_expected = "b";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The characters in test5 are all MAX_CODE_POINT, so the upper bound does not exist.

test8 adds a case where an overflow occurs due to MAX_CODE_POINT, but it is possible to increment the previous character to get an upper bound.

.as("Truncated upper bound should be greater than or equal to the actual upper bound")
.isGreaterThanOrEqualTo(0);

assertThat(cmp.compare(truncateStringMax(Literal.of(test9), 2).value(), test9_2_expected))
Copy link
Contributor

Choose a reason for hiding this comment

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

this is missing an actuall assertion check

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@zhongyujiang
Copy link
Contributor Author

Hey @RussellSpitzer @nastra could you please review this again when you have time?

@RussellSpitzer RussellSpitzer merged commit 3220fad into apache:main Oct 7, 2024
50 checks passed
@zhongyujiang zhongyujiang deleted the dev/fix-metric-malformed branch October 8, 2024 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants