-
Notifications
You must be signed in to change notification settings - Fork 2.2k
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
Core: Fix UnicodeUtil#truncateStringMax returns malformed string. #11161
Conversation
@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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this 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
There was a problem hiding this 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"; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
Hey @RussellSpitzer @nastra could you please review this again when you have time? |
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:
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 inCharacter.MIN_SURROGATE
, which is not a valid Unicode scalar value. Therefore, whenConversions.toByteBuffer
attempted to encode it in UTF-8 format, aMalformedInputException
was thrown.This fix specifically skips illegal code points when incrementing the last character to avoid this issue.
To reproduce