Moritz Manner created FLINK-40832:
-------------------------------------

             Summary: VariantBuilder.of(BigDecimal) writes malformed or wrong 
decimals for negative scale or precision/scale > 38
                 Key: FLINK-40832
                 URL: https://issues.apache.org/jira/browse/FLINK-40832
             Project: Flink
          Issue Type: Bug
          Components: API / Core
            Reporter: Moritz Manner


h3. Problem

{{BinaryVariantInternalBuilder.appendDecimal(BigDecimal)}} writes {{(byte) 
d.scale()}} without validating the decimal. There is no check for a negative 
scale at all. Precision and scale above 38 are only guarded by a Java 
{{assert}} in the DECIMAL16 branch, which only runs with {{-ea}} and is off by 
default. The [Parquet variant 
spec|https://github.com/apache/parquet-format/blob/master/VariantEncoding.md] 
requires a scale in [0, 38] and a precision of at most 38 for decimal4/8/16.

This can be reached through the public {{{}VariantBuilder.of(BigDecimal){}}}, 
e.g. from a UDF that returns a variant:
{code:java}
Variant v = Variant.newBuilder().of(new BigDecimal("1e5")); // scale -5
v.getType();    // DECIMAL
v.getDecimal(); // VariantTypeException: MALFORMED_VARIANT
v.toJson();     // VariantTypeException: MALFORMED_VARIANT

// 41 digits: same as above without -ea, AssertionError with -ea
Variant.newBuilder().of(new BigDecimal("1" + "0".repeat(40)));

// the scale byte wraps, so this silently reads back as 0.1
Variant.newBuilder().of(new BigDecimal("1e2147483647")).toString();
{code}
The reader ({{{}BinaryVariantUtil.getDecimalWithOriginalScale{}}}) checks the 
range, so a broken value is written without error and only fails later when 
it's read. In the wrap-around case it doesn't fail at all and returns a wrong 
value.

A negative scale is easy to get: {{Variant.getDecimal()}} strips trailing 
zeros, so a variant holding 100 returns {{{}1E+2{}}}. Passing that back to 
{{of(BigDecimal)}} gives a malformed variant.

PARSE_JSON is not affected. {{tryParseDecimal}} only accepts plain decimals 
without an exponent and checks the range before calling {{{}appendDecimal{}}}. 
Otherwise it falls back to double.
h3. How Spark handles it

Our builder is a port of Spark's {{{}VariantBuilder{}}}, and Spark's 
{{appendDecimal}} has the same code: no negative scale check and the same 
{{{}assert{}}}. Spark relies on the callers instead:
 * {{parse_json}} uses the same {{tryParseDecimal}} check as we do.
 * {{CAST(... AS VARIANT)}} relies on {{DecimalType}} (precision <= 38, scale 
>= 0 unless the legacy negative scale flag is enabled).
 * The CSV and XML parsers reject a scale below -38, rescale a negative scale 
with {{{}setScale(0){}}}, and then require precision and scale <= 38 
(SPARK-54099, SPARK-55932).

We can't rely on the callers in the same way because 
{{VariantBuilder.of(BigDecimal)}} is public API and accepts any BigDecimal.
h3. Proposed fix

Validate in {{appendDecimal}} with the same rules as Spark's CSV/XML parsers:
 * Rescale a negative scale to 0 with {{{}setScale(0){}}}. This keeps the 
value. Non-zero values with a scale below -38 are rejected first. They can't 
fit anyway, and {{setScale}} is expensive for huge exponents like 
{{{}1e999999999{}}}.
 * Throw a {{VariantTypeException}} if the precision or scale is still above 
38. 



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to