spark-test commented on code in PR #59889:
URL: https://github.com/apache/spark/pull/59889#discussion_r4462394271


##########
sql/api/src/main/scala/org/apache/spark/sql/types/Decimal.scala:
##########
@@ -556,9 +556,30 @@ final class Decimal extends Ordered[Decimal] with 
Serializable {
     if (that.isZero) null
     else Decimal(toJavaBigDecimal.divide(that.toJavaBigDecimal, scale, 
RoundingMode.HALF_UP))
 
-  def %(that: Decimal): Decimal =
-    if (that.isZero) null
-    else Decimal(toJavaBigDecimal.remainder(that.toJavaBigDecimal, 
MATH_CONTEXT))
+  def %(that: Decimal): Decimal = {
+    if (that.isZero) {
+      null
+    } else {
+      val dividend = toJavaBigDecimal
+      val divisor = that.toJavaBigDecimal
+      // Try the context first for speed and for huge trailing-zero quotients 
that exact division
+      // cannot materialize. On failure, retry without its quotient precision 
limit: a quotient
+      // wider than the context may still have a representable remainder.
+      val remainder =
+        try {
+          dividend.remainder(divisor, MATH_CONTEXT)
+        } catch {
+          case error: ArithmeticException =>
+            // Exact division materializes the integral quotient. Two maximum 
SQL decimal
+            // precisions cover in-range SQL operands; wider estimates (legacy 
negative scales or
+            // values beyond SQL bounds) keep the original failure to avoid a 
huge allocation.
+            val quotientDigits = dividend.precision().toLong - 
dividend.scale() + divisor.scale()
+            if (quotientDigits > 2 * DecimalType.MAX_PRECISION) throw error

Review Comment:
   **Non-blocking (P2):** This cutoff leaves part of the failure this PR fixes 
in place. The estimate follows SQL precision rather than the work the remainder 
needs, so with `spark.sql.legacy.allowNegativeScaleOfDecimal=true`:
   
   - `SELECT 1E75BD % 3BD` has an estimate of 76 and now returns 1 through the 
exact retry.
   - `SELECT 1E76BD % 3BD` has an estimate of 77 and still throws the raw 
`java.lang.ArithmeticException: Division impossible`. Its remainder 1 fits the 
`DECIMAL(1,0)` result type, and its exact quotient has 76 digits, the same size 
the retry already builds for `99999999999999999999999999999999999999 % 7E-38`.
   
   `pmod` and numeric `try_mod` split the same way. `try_mod` evaluates 
`Remainder` in TRY mode without catching the exception, so neither it nor 
non-ANSI mode turns the failure into NULL. Direct `Decimal` values beyond SQL 
bounds are affected too, for example `Decimal(1) % Decimal("3E-100")`. In-range 
SQL operands never exceed 76, so default-configuration queries are fully fixed. 
The affected inputs are those whose estimate exceeds 76 while the 39-digit 
context division fails.
   
   The allocation risk the cap guards against comes from materializing the 
quotient, which computing the remainder does not require. Only huge scale gaps, 
such as the -1000000000 dividend scale in the new bound test, make exact 
division expensive.
   
   **Recommended change:** When the 39-digit context remainder fails and the 
quotient-size estimate exceeds 76 digits, compute the remainder with bounded 
work instead of rethrowing: use modular arithmetic on the unscaled values when 
the divisor's scale is at least the dividend's, and the existing exact retry 
otherwise. Representable remainders for legacy negative scales and out-of-range 
Decimal values are then returned. Update the bound comment, and change the 
Decimal remainder-bound tests that now assert the rethrow so they assert the 
computed remainders. Add legacy negative-scale cases for `%` and `pmod` to the 
arithmetic expression tests, and for numeric `try_mod` to the TRY-mode 
remainder test.
   
   **Why this works:** Let u1 and u2 be the dividend's and divisor's unscaled 
values and s1 and s2 their scales. When the divisor scale is at least the 
dividend scale, the truncated remainder is sign(dividend) * ((|u1| mod |u2|) * 
(10^(s2 - s1) mod |u2|) mod |u2|), at the divisor's scale. BigInteger.modPow 
computes the power of ten modulo |u2| in steps logarithmic in the scale gap, so 
no wide quotient or large power of ten is ever materialized. When the dividend 
scale is larger, a failing context division implies the dividend has more 
digits than the scale gap, so the existing exact retry stays bounded by the 
operand sizes and can be used for any estimate. The context call stays first, 
and the exact retry for estimates up to 76 stays as it is, so every result the 
base branch or the current fallback produces keeps its value and scale.
   
   **Scope:** Extend the remainder fallback in Decimal.% so inputs above the 
current quotient-size cap are computed with bounded work rather than rethrown, 
and update the remainder tests to match.
   
   **Compatibility:** The 39-digit context remainder stays the first attempt, 
and the exact retry still handles estimates up to 76 digits, so every value and 
scale produced today, on the base branch or by the current fallback, is 
unchanged. Zero divisors still return null.
   
   **Risks:** Results on the new modular branch take the divisor's scale, the 
larger operand scale. This matches the SQL remainder result scale. Exact 
BigDecimal division can return a smaller scale when the quotient has trailing 
zeros, so direct Decimal callers may get the divisor's scale where exact 
division would return a smaller one, on inputs that throw today. The new 
arithmetic must keep the dividend's sign and ignore the divisor's sign. A sign 
mistake would silently return wrong remainders, so every operand-sign 
combination needs coverage.
   
   **Constraints:** Keep the 39-digit context call first and keep the exact 
retry for estimates up to 76, so existing results keep their values and scales. 
Compute the power of ten modulo the divisor's unscaled value (for example with 
BigInteger.modPow), so the work grows with the logarithm of the scale gap and 
never with the gap itself.
   
   **Exclusions:**
   - Integral division (Decimal.quot and SQL div) still throws the raw 
exception for integral quotients wider than 39 digits. That is a separate 
existing error-classification issue for a quotient that cannot fit its result 
type, and it is left unchanged.
   
   **Success:** With spark.sql.legacy.allowNegativeScaleOfDecimal enabled, 
1E76BD % 3BD returns 1, 1E100BD % 7BD returns 4, pmod(-1E76BD, 3BD) returns 2, 
and try_mod(1E76BD, 3BD) returns 1, in both interpreted and generated 
evaluation. Decimal(1L, 1, -76) % Decimal(3L), Decimal(1L, 1, -1000000000) % 
Decimal(3L), and Decimal(1L, 1, Int.MinValue) % Decimal(3L) return 1 without 
materializing the quotient. Decimal(1) % Decimal("3E-100") returns 1E-100, and 
Decimal(1) % Decimal("3E-100000000") returns 1E-100000000 promptly. A direct 
dividend wider than 76 digits returns its exact remainder whether its scale is 
at most or above the divisor's: 10^77 at scale 0 % 3 and 10^89 at scale 10 (100 
digits) % 3 both return 1. Every input that the 39-digit context or the current 
exact retry already handles keeps its value and scale, and a zero divisor still 
returns null.
   
   **Verification:**
   - **Behavior:** Remainders whose quotient-size estimate exceeds the current 
76-digit cap are returned exactly, with the dividend's sign, for `%`, `pmod`, 
and numeric `try_mod` under the legacy negative-scale config, through 
interpreted and generated evaluation. Cases include 1E76BD % 3BD = 1, 1E100BD % 
7BD = 4, and pmod(-1E76BD, 3BD) = 2.
   - **Regression:** Every input whose estimate exceeds 76 and whose context 
division fails returns its exact remainder, with work bounded by the operand 
sizes and the logarithm of the scale gap, on both fallback branches and in all 
four operand-sign combinations. Dividend scales -76, -1000000000, and 
Int.MinValue with divisor 3 return 1. Dividend 1 with divisors 3E-100 and 
3E-100000000 returns 1E-100 and 1E-100000000. Direct dividends wider than 76 
digits return 1 for 10^77 at scale 0 % 3 and for 10^89 at scale 10 % 3. These 
replace the current assertions that such inputs rethrow the context failure.
   - **Compatibility:** Inputs that the 39-digit context already handles, and 
in-range inputs that the exact retry already fixes, keep identical values and 
scales in all four operand-sign combinations. A zero divisor still returns null.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/types/DecimalSuite.scala:
##########
@@ -245,6 +247,138 @@ class DecimalSuite extends SparkFunSuite with 
PrivateMethodTester with SQLHelper
     assert(Decimal(100) % Decimal(0) === null)
   }
 
+  Seq(
+    ("10000000000000000000000000000000000000", "0.0007", "0.0005"),
+    ("99999999999999999999999999999999999999", "0.0007", "0.0004"),
+    ("99999999999999999999999999999999999999", "7E-38", "2E-38"),
+    ("99999999999999999999999999999999999999", "1E-38", "0")
+  ).foreach { case (dividend, divisor, expected) =>
+    test(s"decimal remainder with a large integral quotient: $dividend % 
$divisor") {
+      val left = Decimal(dividend)
+      val right = Decimal(divisor)
+      val remainder = Decimal(expected)
+      assert(left % right == remainder)
+      assert(left % -right == remainder)
+      assert(-left % right == -remainder)
+      assert(-left % -right == -remainder)
+      assert((left % right).scale == remainder.scale)
+      assert(left % Decimal(0) == null)
+    }
+  }
+
+  Seq(
+    (38, "99999999999999999999999999999999999999", "7", "1", 38),
+    (39, "99999999999999999999999999999999999999", "0.7", "0.3", 39),
+    (40, "99999999999999999999999999999999999999", "0.07", "0.02", 40),
+    (40, "10000000000000000000000000000000000000", "0.07", "0.06", 39)
+  ).foreach { case (bound, dividend, divisor, expected, quotientDigits) =>
+    test(s"decimal remainder with quotient bound $bound and $quotientDigits 
actual digits") {
+      val left = Decimal(dividend)
+      val right = Decimal(divisor)
+      val quotient = 
left.toJavaBigDecimal.divideToIntegralValue(right.toJavaBigDecimal)
+      assert(quotient.toBigInteger.toString.length == quotientDigits)
+      for (leftSign <- Seq(1, -1); rightSign <- Seq(1, -1)) {
+        val signedLeft = if (leftSign < 0) -left else left
+        val signedRight = if (rightSign < 0) -right else right
+        val remainder = if (leftSign < 0) -Decimal(expected) else 
Decimal(expected)
+        assert((signedLeft % signedRight).toJavaBigDecimal == 
remainder.toJavaBigDecimal)
+      }
+    }
+  }
+
+  test("decimal remainder preserves zero quotients, trailing zeros and compact 
operands") {
+    Seq(
+      (Decimal("0.00"), Decimal("0.7"), Decimal("0.00")),
+      (Decimal("1.00"), Decimal("7.0"), Decimal("1.00")),
+      (Decimal(120000L, 6, 2), Decimal(70L, 2, 1), Decimal("3.00")),
+      // The 40-digit quotient is representable by the old context because it 
has trailing zeros.

Review Comment:
   **Nit (P3):** `%` still tries this same 39-digit `MATH_CONTEXT` first, and 
there is no other context, so after merge "old" refers to nothing. It also 
hides what the case shows: 1E+39 has a single significant digit, so this case 
stays on the context path and never reaches the exact retry.
   
   ```suggestion
         // The 39-digit context represents this 40-digit quotient because it 
has trailing zeros.
   ```
   
   **Verification:**
   - **Inspection:** The comment above the trailing-zero remainder case names 
the current 39-digit context rather than an old one, and no assertion in that 
test changes.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to