[FLINK-40350][table-runtime] LPAD/RPAD split supplementary-plane characters into unpaired surrogates - #29004
[FLINK-40350][table-runtime] LPAD/RPAD split supplementary-plane characters into unpaired surrogates#29004SEPURI-SAI-KRISHNA wants to merge 3 commits into
Conversation
SqlFunctionUtils#lpad and #rpad allocated char[len] and indexed the input with
String#length() and charAt, both of which count UTF-16 code units. A length
falling inside a supplementary-plane character therefore split the surrogate
pair and returned invalid Unicode, and a base or pad holding such a character
produced a result with fewer characters than requested:
LPAD(E, 1, 'x') -> lone high surrogate
RPAD(E, 1, 'x') -> lone high surrogate
RPAD('a', 4, E) -> pad split mid-pair, trailing lone high surrogate
LPAD(E, 3, 'x') -> 2 characters instead of 3
where E stands for U+1F600 GRINNING FACE, encoded in UTF-16 as the surrogate
pair D83D DE00.
The documented contract is "a length of integer characters", and a
supplementary-plane character is one character, so the input should be returned
unchanged rather than halved. This also matches SPLIT, which moved to code-point
iteration in FLINK-36267.
Measure both base and pad in code points and slice on character boundaries,
building the result with a StringBuilder. Padding is shared by both functions in
appendPadding, which repeats the pad string cyclically and stops mid-string only
on a character boundary. Scanning of the base stops after len characters, so
truncating a base far longer than the requested length keeps the complexity of
the previous implementation. Behaviour for Basic Multilingual Plane input is
unchanged.
Generated-by: Claude Code (Opus 5)
… comments Compute the result size up front rather than assuming len chars, so the exact-size char[] of the original implementation is kept instead of a StringBuilder, and cut the helper Javadoc to one line each. Also cover null base, null pad and null length for LPAD and RPAD, which the test set was missing. Generated-by: Claude Code (Opus 5)
|
Thanks for the review, all three are addressed in the new commit.
I have also added null cases, which the test set was missing: null base, null While re-checking I also confirmed this is inconsistent with the neighbouring Both Verification on the new revision: reverting only the |
| int baseEnd = endOfCodePoints(base, len); | ||
| int padChars = padLength(pad, len - base.codePointCount(0, baseEnd)); | ||
| char[] data = new char[padChars + baseEnd]; |
There was a problem hiding this comment.
| int baseEnd = endOfCodePoints(base, len); | |
| int padChars = padLength(pad, len - base.codePointCount(0, baseEnd)); | |
| char[] data = new char[padChars + baseEnd]; | |
| final int baseEnd = endOfCodePoints(base, len); | |
| final int padChars = padLength(pad, len - base.codePointCount(0, baseEnd)); | |
| final char[] data = new char[padChars + baseEnd]; |
There was a problem hiding this comment.
Done. Two optional follow-ups here, want either?
base.codePointCount(0, baseEnd)re-walks whatendOfCodePointsjust walked; folding it in means inlining the loop in both methods.- when
padChars == 0,base.substring(0, baseEnd)skips a copy.
| private static void writePad(char[] data, int pos, String pad, int chars) { | ||
| int end = pos + chars; | ||
| while (end - pos >= pad.length()) { | ||
| pad.getChars(0, pad.length(), data, pos); |
There was a problem hiding this comment.
why do we need to write on every iteration?
There was a problem hiding this comment.
this is not true
why do we still have
System.arraycopy(data, pos, data, pos + written, next);inside a loop?
There was a problem hiding this comment.
You are right that a loop is still there, but the write is no longer per pad cycle: each pass doubles the region already written, so it is log2 rather than linear. For chars = 1000000, padLen = 2 that is 20 writes instead of 500001.
The JDK has no loop-free fill for a multi-char pattern. Want Arrays.fill for the single-char pad case, which is the common one?
There was a problem hiding this comment.
why can't we count amount of symbols to pad in a loop
and then separately execute only 1 pad operation?
There was a problem hiding this comment.
So i tried it: pad.repeat(n) then one getChars allocates the padding as a String first, so it measures slower, 309 vs 142 ns at 1000 chars.
String.repeat is itself this loop: JDK 17 does System.arraycopy with copied <<= 1 for a multi-char string, and Arrays.fill when the string is 1 char.
The second half looks worth taking: Arrays.fill for padLen == 1 is faster up to ~1000 chars (4 vs 18 ns at 8 chars, 11 vs 39 at 128). Add that and keep the doubling for longer pads?
snuyanzin
left a comment
There was a problem hiding this comment.
@SEPURI-SAI-KRISHNA please do NOT flood with your AI in comments.
What is the reason to write a poem here?
Also PR's tittle Measure LPAD/RPAD length in characters says nothing about the actual issue
…nal locals Fill the padding by doubling an initial copy with System.arraycopy instead of writing the pad on every iteration. Drop the LPAD/RPAD cases from ScalarFunctionsTest; they duplicate the ones in StringFunctionsITCase. Generated-by: Claude Code (Opus 5)
What is the purpose of the change
LPADandRPADmeasure length in UTF-16 code units instead of characters. When the requested length falls in the middle of a supplementary-plane character, the function splits the surrogate pair and returns a string containing an unpaired surrogate, which is not valid Unicode. The same code-unit arithmetic also makes the result shorter than requested whenever the base or the pad string contains such a character.In the table below
Estands for the single-character string U+1F600 GRINNING FACE, which UTF-16 encodes as the surrogate pair U+D83D U+DE00. It is written asEso that this description stays within the Basic Multilingual Plane.LPAD(E, 1, 'x')U+D83DERPAD(E, 1, 'x')U+D83DERPAD('a', 4, E)U+0061 U+D83D U+DE00 U+D83DLPAD(E, 3, 'x')U+0078 U+D83D U+DE00This disagrees with the rest of the string functions, which already count characters rather than code units:
CHAR_LENGTH(E)is1, becauseBinaryStringData#numCharsadvances by UTF-8 lead byteSUBSTR(E, 1, 1)returnsEintact, becauseSUBSTRINGis generated againstBinaryStringDataSo
SUBSTR(E, 1, 1)andLPAD(E, 1, 'x')are both "take the first 1 character ofE" and give different answers, one of them not valid Unicode. The documentation agrees with the former:Other engines return the character intact. Checked against Spark
(
spark-sql, results shown as UTF-8 hex,F09F9880isE):lpad(E, 1, 'x')F09F9880U+D83DaloneF09F9880rpad(E, 1, 'x')F09F9880U+D83DaloneF09F9880rpad('a', 4, E)61F09F9880F09F9880F09F9880U+D83D61F09F9880F09F9880F09F9880lpad(E, 3, 'x')7878F09F98807878F09F9880LPADandRPADare the only affected functions.SUBSTRINGandOVERLAYare generated againstBinaryStringData, andSqlFunctionUtils#subStringand#overlayare not wired to any operator;initcapwalks code units but rewrites only ASCII ranges and copies everything else through unchanged, so it never splits a pair.The failure is silent: no exception is thrown and nothing is logged. The result is invalid UTF-16 and does not round-trip, so downstream string functions and comparisons then operate on a value the query never produced.
This is the same class of issue as FLINK-36267, which moved
SPLITto code-point iteration.Brief change log
SqlFunctionUtils#lpadand#rpadmeasure both the base and the pad string in code points and slice only on character boundarieschar[]. The size is no longerlen, since a supplementary-plane character occupies two chars, so it is computed from the kept part of the base plus the paddinglencharacters, so truncating a base far longer than the requested length no longer copies the whole baseVerifying this change
This change added tests and can be verified as follows:
lpadRpadTestCasestoStringFunctionsITCase, which exercises the generated runtime code with field references rather than literals so the cases are not reduced at plan time. It covers a null base, a null pad and a null length, an empty pad, a negative length, truncation on a character boundary, padding with a supplementary-plane pad, a multi-character pad whose cycle is split mid-repeat, an empty base, and Basic Multilingual Plane cases that pin existing behaviourSqlFunctionUtilschange fails 36 of the 613StringFunctionsITCaseassertions with results such asexpected: +I[<emoji>] but was: +I[?]; all 613 pass with the change applied. Theflink-table-runtimesuite is 1813/1813Does this pull request potentially affect one of the following parts:
@Public(Evolving): no.SqlFunctionUtilsis an internal runtime class, though the observable result of theLPADandRPADSQL functions changes for supplementary-plane input, which is the point of the fixlpad("order-id-99213", 40, " ")148.5 ns/op before and 127.9 ns/op after;lpad(<100k-char base>, 10, " ")411.5 ms before and 0.6 ms after, since the current code callstoCharArray()on the whole base before discarding almost all of itDocumentation
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 5)