Skip to content

[SPARK-60119][SQL][4.1] Skip rescaling in Decimal.changePrecision when the result is known from the magnitude - #59514

Closed
viirya wants to merge 1 commit into
apache:branch-4.1from
viirya:SPARK-60119-4.1
Closed

viirya wants to merge 1 commit into
apache:branch-4.1from
viirya:SPARK-60119-4.1

Conversation

@viirya

@viirya viirya commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Backport of #59335 to branch-4.1.

The cherry-pick conflicted only on CastUtils.java, which does not exist on this branch: Cast calls QueryExecutionErrors.cannotChangeDecimalPrecisionError directly in both the interpreted and generated code. So instead of removing that method and switching CastUtils, this backport keeps it and makes it delegate to DataTypeErrors.cannotChangeDecimalPrecisionError, which gives the same error message. The rest of the change is identical to #59335.

In the BigDecimal branch of Decimal.changePrecision, when the scale change exceeds DecimalType.MAX_PRECISION, the value's number of integral digits (precision - scale, computed in Long) is checked before calling setScale:

  • If the value is nonzero and has more integral digits than the target type allows, return false (overflow) directly. Rounding cannot bring it back into range.
  • If the value is below 0.01 ulp of the target scale, the result depends only on the sign and the rounding mode, so a same-sign value of 0.01 ulp is rescaled instead. This gives the same result for every rounding mode.

The Long branch of changePrecision now computes the scale change in Long, so it no longer wraps and throws ArrayIndexOutOfBoundsException from POW_10(diff) when negative scales are allowed.

NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION now renders a decimal whose scale is below -1000 (only possible under spark.sql.legacy.allowNegativeScaleOfDecimal) with toString instead of toPlainString, since the plain form of e.g. 1E+2147483647 does not fit in a String. Other values are rendered as before. QueryExecutionErrors.cannotChangeDecimalPrecisionError, an identical copy, now delegates to DataTypeErrors.cannotChangeDecimalPrecisionError.

Why are the changes needed?

setScale takes time that grows with the scale change, and throws once 10^change exceeds BigInteger's range or the change exceeds the Int range:

SELECT CAST('1e-2147483647' AS DECIMAL(10,2));
-- java.lang.ArithmeticException: BigInteger would overflow supported range  (LEGACY, ANSI and TRY)
SELECT CAST('1e-100000000' AS DECIMAL(10,2));
-- 0.00, after ~40s

SET spark.sql.legacy.allowNegativeScaleOfDecimal=true;
SELECT CAST('1e2147483647' AS DECIMAL(10,2));
-- java.lang.ArithmeticException: Underflow

This branch already has the related fix SPARK-60118, so backporting this keeps decimal casts consistent with master.

Does this PR introduce any user-facing change?

Yes, the same as #59335:

  • Casting a string whose value is far below the target scale's ulp now returns the rounded value (e.g. 0.00) instead of throwing ArithmeticException, and returns quickly.
  • With spark.sql.legacy.allowNegativeScaleOfDecimal=true, casting a string with a huge exponent returns NULL (non-ANSI, try_cast) or throws NUMERIC_VALUE_OUT_OF_RANGE (ANSI) instead of ArithmeticException: Underflow, and a decimal stored as a long no longer throws ArrayIndexOutOfBoundsException for a scale change beyond the Int range. In that error, a value whose scale is below -1000 is shown in scientific notation.

No input that previously produced a value changes its result.

How was this patch tested?

The tests from #59335 in DecimalSuite, CastSuiteBase, CastWithAnsiOnSuite and CastWithAnsiOffSuite (also run through TryCastSuite). On this branch with JDK 17, {'4.3': '512 tests across DecimalSuite, CastWithAnsiOnSuite, CastWithAnsiOffSuite, TryCastSuite, DecimalPrecisionSuite, DecimalExpressionSuite and MathExpressionsSuite, plus UdfSerializationSuite (16 tests)', '4.2': '410 tests across DecimalSuite, CastWithAnsiOnSuite, CastWithAnsiOffSuite, TryCastSuite, DecimalPrecisionSuite, DecimalExpressionSuite and MathExpressionsSuite', '4.1': '403 tests across DecimalSuite, CastWithAnsiOnSuite, CastWithAnsiOffSuite, TryCastSuite, DecimalPrecisionSuite, DecimalExpressionSuite and MathExpressionsSuite', '4.0': '354 tests across DecimalSuite, CastWithAnsiOnSuite, CastWithAnsiOffSuite, TryCastSuite, DecimalPrecisionSuite, DecimalExpressionSuite and MathExpressionsSuite'} pass, and scalastyle (sql-api, catalyst, catalyst tests) and the sql/api scalafmt check pass.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

… result is known from the magnitude

### What changes were proposed in this pull request?

In the BigDecimal branch of `Decimal.changePrecision`, when the scale change exceeds `DecimalType.MAX_PRECISION`, the value's number of integral digits (`precision - scale`, computed in Long) is checked before calling `setScale`:

- If the value is nonzero and has more integral digits than the target type allows, return `false` (overflow) directly. Rounding cannot bring it back into range.
- If the value is below 0.01 ulp of the target scale, the result depends only on the sign and the rounding mode, so a same-sign value of 0.01 ulp is rescaled instead. This gives the same result for every rounding mode.

Otherwise the remaining scale change is bounded by the number of digits of the value, so `setScale` is unchanged. The extra check costs one Int comparison when the scale change is small, which is the common case.

The Long branch of `changePrecision` now computes the scale change in Long. With negative scales allowed it can exceed the Int range and wrap, so `POW_10(diff)` threw `ArrayIndexOutOfBoundsException`, e.g. for a compact `1.00` changed to `DECIMAL(1, -2147483647)`. It now takes the existing rounding path or switches to BigDecimal.

`NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION` now renders a decimal whose scale is below -1000 (only possible under `spark.sql.legacy.allowNegativeScaleOfDecimal`) with `toString` instead of `toPlainString`, since the plain form of e.g. `1E+2147483647` does not fit in a String. Other values are rendered as before. `QueryExecutionErrors.cannotChangeDecimalPrecisionError`, an identical copy, now delegates to `DataTypeErrors.cannotChangeDecimalPrecisionError`.

### Why are the changes needed?

`setScale` takes time that grows with the scale change, and throws once 10^change exceeds BigInteger's range or the change exceeds the Int range:

```sql
SELECT CAST('1e-2147483647' AS DECIMAL(10,2));
-- java.lang.ArithmeticException: BigInteger would overflow supported range  (LEGACY, ANSI and TRY)
SELECT CAST('1e-100000000' AS DECIMAL(10,2));
-- 0.00, after ~40s

SET spark.sql.legacy.allowNegativeScaleOfDecimal=true;
SELECT CAST('1e2147483647' AS DECIMAL(10,2));
-- java.lang.ArithmeticException: Underflow
```

This is the small-side counterpart of the fast-fail added in SPARK-35841/SPARK-37451. With negative scales allowed, `Decimal.fromString` intentionally skips that fast-fail, so huge values reach `changePrecision` too.

### Does this PR introduce _any_ user-facing change?

Yes.
- Casting a string whose value is far below the target scale's ulp now returns the rounded value (e.g. 0.00) instead of throwing `ArithmeticException`, and returns quickly.
- With `spark.sql.legacy.allowNegativeScaleOfDecimal=true`, casting a string with a huge exponent returns NULL (non-ANSI, try_cast) or throws `NUMERIC_VALUE_OUT_OF_RANGE` (ANSI) instead of `ArithmeticException: Underflow`. In that error, a value whose scale is below -1000 (e.g. `1E+2147483647`) is shown in scientific notation, since its plain form is too long to build. Other values are shown as before.
- With `spark.sql.legacy.allowNegativeScaleOfDecimal=true`, changing a decimal stored as a long (precision up to 18, e.g. after an `UnsafeRow` round trip) by a scale change beyond the Int range no longer throws `ArrayIndexOutOfBoundsException`, and gives the same result as the same value stored as a BigDecimal.

No input that previously produced a value changes its result.

### How was this patch tested?

New tests in `DecimalSuite`, `CastSuiteBase` (so they run in `CastWithAnsiOffSuite`, `CastWithAnsiOnSuite` and `TryCastSuite`), `CastWithAnsiOffSuite` and `CastWithAnsiOnSuite`, covering interpreted and codegen evaluation. All of them fail without the fix. The ANSI test also checks the `value` parameter of the error, in scientific notation for `1e2147483647` and in plain notation for `1e40`. `DecimalSuite` also checks that compact (long) inputs, including scale changes beyond the Int range, round like the same values in BigDecimal form, and that values around the bounds of both shortcuts, with short and 81-digit mantissas, under every supported rounding mode and with and without negative scales, round exactly like `BigDecimal.setScale`.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

This pull request and its description were written by Isaac.

Closes apache#59335 from viirya/SPARK-60119.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>

(cherry picked from commit 9c3a51f)
@viirya
viirya requested a review from dongjoon-hyun October 15, 2026 21:32
viirya added a commit that referenced this pull request Oct 16, 2026
…n the result is known from the magnitude

### What changes were proposed in this pull request?

Backport of #59335 to branch-4.1.

The cherry-pick conflicted only on `CastUtils.java`, which does not exist on this branch: `Cast` calls `QueryExecutionErrors.cannotChangeDecimalPrecisionError` directly in both the interpreted and generated code. So instead of removing that method and switching `CastUtils`, this backport keeps it and makes it delegate to `DataTypeErrors.cannotChangeDecimalPrecisionError`, which gives the same error message. The rest of the change is identical to #59335.

In the BigDecimal branch of `Decimal.changePrecision`, when the scale change exceeds `DecimalType.MAX_PRECISION`, the value's number of integral digits (`precision - scale`, computed in Long) is checked before calling `setScale`:

- If the value is nonzero and has more integral digits than the target type allows, return `false` (overflow) directly. Rounding cannot bring it back into range.
- If the value is below 0.01 ulp of the target scale, the result depends only on the sign and the rounding mode, so a same-sign value of 0.01 ulp is rescaled instead. This gives the same result for every rounding mode.

The Long branch of `changePrecision` now computes the scale change in Long, so it no longer wraps and throws `ArrayIndexOutOfBoundsException` from `POW_10(diff)` when negative scales are allowed.

`NUMERIC_VALUE_OUT_OF_RANGE.WITH_SUGGESTION` now renders a decimal whose scale is below -1000 (only possible under `spark.sql.legacy.allowNegativeScaleOfDecimal`) with `toString` instead of `toPlainString`, since the plain form of e.g. `1E+2147483647` does not fit in a String. Other values are rendered as before. `QueryExecutionErrors.cannotChangeDecimalPrecisionError`, an identical copy, now delegates to `DataTypeErrors.cannotChangeDecimalPrecisionError`.

### Why are the changes needed?

`setScale` takes time that grows with the scale change, and throws once 10^change exceeds BigInteger's range or the change exceeds the Int range:

```sql
SELECT CAST('1e-2147483647' AS DECIMAL(10,2));
-- java.lang.ArithmeticException: BigInteger would overflow supported range  (LEGACY, ANSI and TRY)
SELECT CAST('1e-100000000' AS DECIMAL(10,2));
-- 0.00, after ~40s

SET spark.sql.legacy.allowNegativeScaleOfDecimal=true;
SELECT CAST('1e2147483647' AS DECIMAL(10,2));
-- java.lang.ArithmeticException: Underflow
```

This branch already has the related fix SPARK-60118, so backporting this keeps decimal casts consistent with master.

### Does this PR introduce _any_ user-facing change?

Yes, the same as #59335:
- Casting a string whose value is far below the target scale's ulp now returns the rounded value (e.g. 0.00) instead of throwing `ArithmeticException`, and returns quickly.
- With `spark.sql.legacy.allowNegativeScaleOfDecimal=true`, casting a string with a huge exponent returns NULL (non-ANSI, try_cast) or throws `NUMERIC_VALUE_OUT_OF_RANGE` (ANSI) instead of `ArithmeticException: Underflow`, and a decimal stored as a long no longer throws `ArrayIndexOutOfBoundsException` for a scale change beyond the Int range. In that error, a value whose scale is below -1000 is shown in scientific notation.

No input that previously produced a value changes its result.

### How was this patch tested?

The tests from #59335 in `DecimalSuite`, `CastSuiteBase`, `CastWithAnsiOnSuite` and `CastWithAnsiOffSuite` (also run through `TryCastSuite`). On this branch with JDK 17, {'4.3': '512 tests across `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite`, `TryCastSuite`, `DecimalPrecisionSuite`, `DecimalExpressionSuite` and `MathExpressionsSuite`, plus `UdfSerializationSuite` (16 tests)', '4.2': '410 tests across `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite`, `TryCastSuite`, `DecimalPrecisionSuite`, `DecimalExpressionSuite` and `MathExpressionsSuite`', '4.1': '403 tests across `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite`, `TryCastSuite`, `DecimalPrecisionSuite`, `DecimalExpressionSuite` and `MathExpressionsSuite`', '4.0': '354 tests across `DecimalSuite`, `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite`, `TryCastSuite`, `DecimalPrecisionSuite`, `DecimalExpressionSuite` and `MathExpressionsSuite`'} pass, and scalastyle (`sql-api`, `catalyst`, `catalyst` tests) and the `sql/api` scalafmt check pass.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 5.5)

Closes #59514 from viirya/SPARK-60119-4.1.

Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
@viirya viirya closed this Oct 16, 2026
@viirya

viirya commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Merge Summary:

Posted by merge_spark_pr.py

@viirya

viirya commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Thanks @HyukjinKwon !

@viirya
viirya deleted the SPARK-60119-4.1 branch October 16, 2026 01:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants