Conversation
f4a027d to
e767210
Compare
|
This may overlap with #4799 |
|
Thanks @andygrove and @comphead, sorry for missing #4799! That would be great if it covers it. |
|
This is a small targeted PR that seems ready for review and #4799 is still in flux. Let's reopen this one. |
andygrove
left a comment
There was a problem hiding this comment.
Thanks for this @0lai0. The analysis in the description is careful and mostly holds up. I traced every pow(scale as u32) site in native/ against your guards, and the ones you deliberately left native really are safe:
- float/double →
Decimal(neg):cast_floating_point_to_decimal128uses10_f64.powi(scale as i32)(numeric.rs:901) - string ↔
Decimal(neg): sign-checkedi32branches (string.rs:603-620) Decimal(neg)→ Boolean:spark_cast_decimal_to_booleanhas no scale math (numeric.rs:860)Multiply: the wide path'sscale_diffbranches are sign-checked (wide_decimal_binary_expr.rs:256-275), and the narrow path short-circuits past the panicking subtraction in the planner guard, so leaving it unguarded is correctAvg:target_scale - sum_scaleis(s+4) - s = 4regardless of sign, soavg_decimal.rs:382is fineCeil/Floor/Roundwere already guarded
Also agree the Scala suites are the right home here rather than CometSqlFileTestSuite, since negative scale cannot be written in SQL.
A few things to address.
Rebase needed
This is showing as conflicting with main and no CI has run on the branch. CometCast has changed since 27 July, it now mixes in CometTypeShim and CodegenDispatchFallback, and getSupportLevel gained a literal-child short-circuit. Could you rebase so we get a green CI signal? I would like to see the new tests pass on a debug build before merging, since debug is where the panic surfaces.
Boolean → Decimal(negative scale) looks like the same bug, unguarded
CometCast.scala:347 (canCastFromBoolean) returns Compatible() for _: DecimalType with no scale check, and this PR does not touch it. cast.rs:441 sends (Boolean, Decimal128(p, s)) to cast_boolean_to_decimal, which does 10_i128.pow(scale as u32) at boolean.rs:37. That is the identical pattern you are guarding everywhere else.
Does col("b").cast(DecimalType(10, -1)) still panic with attempt to multiply with overflow? If so it needs the same case d: DecimalType if d.scale < 0 guard, plus a test alongside the integer ones.
Consolidating the negative-scale check
After this PR there are five places testing d.scale < 0 with four different message strings: the two new constants here, negativeScaleDecimalToStringReason, and CometCeil/CometFloor/CometRound each carrying their own "Decimal type has negative scale".
Would it be worth pulling out a single shared predicate, something like CometCast.isNegativeScaleDecimal(dt) with one reason string? The Boolean gap above is exactly the kind of thing that is easy to miss when the check is scattered, and this family of guards is likely to keep growing.
The native underflow stays armed
The attempt to subtract with overflow in #5013 comes from the match guard at native/core/src/execution/planner.rs:918:
max(s1, s2) as u8 + max(p1 - s1 as u8, p2 - s2 as u8) >= DECIMAL128_MAX_PRECISIONWith s1 = -1, s1 as u8 is 255 and p1 - 255 underflows the u8. In release builds overflow-checks = false means it wraps to p1 + 1 and we quietly take a different branch instead of panicking.
Your Scala guards mean we should not reach this anymore, but could we also do that arithmetic in i16? It is a small change and it turns a landmine into an ordinary error for any future path that reaches native with a negative scale. If you would rather keep this PR tight, could you file a follow-up issue and link it here?
Test coverage is only DecimalType(10, -1)
CometCastSuite.scala:797 and CometExpressionSuite.scala:1054 both use DecimalType(10, -1), but #5013 also reproduced with DecimalType(20, -5), and the scale magnitude selects a different native branch. create_binary_expr_with_options routes wide operands to WideDecimalBinaryExpr rather than arrow's BinaryExpr, and decimal_div picks its BigInt path over the i128 one.
Could you parameterise over both DecimalType(10, -1) and DecimalType(20, -5)? It matters most for the v * v pin in "safe ops on negative-scale decimal run natively", since right now that only proves the narrow arrow path is safe.
Documentation
Two gaps worth closing.
The generated cast matrix only samples createDecimalType(10, 2) from CometCast.supportedTypes, so none of the new Unsupported pairs appear in the table and users have no way to learn about them. _category_template/cast.md:153 already has a hand-maintained "Decimal with Negative Scale to String" section, so a companion section covering integer ↔ Decimal(neg) and Decimal(neg) → Timestamp would fit naturally there.
On the arithmetic side, Add, Subtract, Divide, IntegralDivide and Remainder are all in QueryPlanSerde.mathExpressions:111, which means they get a math.md entry built from getUnsupportedReasons(). Could the five serdes override that to return negScaleDecimalArithmeticReason? Otherwise the compat page shows them as fully supported with no caveat.
| toType match { | ||
| // Negative-scale source overflows natively; see #5013. | ||
| case DataTypes.ByteType | DataTypes.ShortType | DataTypes.IntegerType | DataTypes.LongType | | ||
| DataTypes.TimestampType if fromType.scale < 0 => |
There was a problem hiding this comment.
should we do early return here?
| private[comet] val negScaleDecimalArithmeticReason: String = | ||
| "Arithmetic on negative-scale decimal is not supported natively" | ||
|
|
||
| private[comet] def negScaleDecimalRejection(expr: BinaryArithmetic): Option[Unsupported] = { |
There was a problem hiding this comment.
do we really need changes in this file?
|
@andygrove @0lai0 WDYT instead of listing ops if we scan an entire input plan and fallback if there is negative decimals? |
|
Thanks @comphead. Good point on nested types, my guards match On plan-level scanning or per-expression guards, I don't have a strong view either way. |
|
One suggestion: I'd build it on Nothing is needed on the scan side — the Parquet spec requires The tests you've added are the valuable part here and will cover this just as well. |
A panic in debug and a silent wrong answer in release is exactly the kind of thing worth closing off quickly, and doing it at the serde boundary is a reasonable conservative first move. The comment on Four questions. Where does Since #4728, If the dispatcher does handle it correctly, a test asserting that a negative-scale cast runs through the dispatcher and matches Spark would be worth having. If it does not, these need The native kernel is still unsafe Guarding in Scala stops the Spark planner from reaching the kernel, but Only
Is the The comment says multiply does not scale-align, so negative scale is safe. |
02c1291 to
36d748f
Compare
|
Thanks @andygrove and @comphead. Where
|
andygrove
left a comment
There was a problem hiding this comment.
Note on this review: the prose here was generated by an LLM (Claude Code) at my request while I worked through a review backlog. Unlike my previous LLM-assisted comment on this PR, the claims below were checked against a local debug build rather than inferred from reading, and I've noted where a check was attempted and left unresolved. Still, please treat it as suggestions to evaluate rather than as authoritative feedback, and push back on anything that's wrong or already handled.
Thanks for picking this back up. The rebase is clean, the assertCodegenRan assertions are exactly the right way to pin the dispatcher routing, and the audit you posted of the other rescaling sites holds up. I checked -v, abs(v), sum(v), avg(v), v * v and v * decimal(10,0) against a debug build on both Decimal(10,-1) and Decimal(20,-5) and they all run natively and match Spark, so the exemptions you argued for are right.
First, a process note. CI has never run on this branch. All six workflow runs on 036c2e0 are sitting at action_required waiting on fork-workflow approval, which is on me rather than you. I'll approve them. In the meantime I built the merge locally and ran CometNativeCastSuite and CometExpressionSuite on the default profile against a debug native lib, and got 331 passing with 0 failures, so the PR as written is green.
The substance of what I found is that two of the guards are load-bearing, two are not, and there's a third copy of the underflow you're fixing that the PR doesn't touch. I isolated this by checking out the merge, keeping your planner.rs rewrite, reverting the Scala guards to main, and running each case against a debug build over a value set covering zero, negatives, nulls and max-magnitude values.
The float and double guard looks obsolete
I think the FloatType / DoubleType half of the canCastFromDecimal guard is no longer needed. #5684 landed on main on 2026-09-05, one revision before your merge commit, and it replaced arrow's (unscaled as f64) / 10^scale fall-through with decimal128_to_f64 and decimal128_to_f32 (native/spark-expr/src/conversion_funcs/numeric.rs:910 and :934). Those handle negative scale deliberately. The -22..=-1 arm multiplies by an exact F64_EXACT_POW10 entry, and anything outside that range goes through parse_exact_decimal, which formats the value as <unscaled>e<-scale> and hands it to Rust's correctly rounded float parser.
With that guard reverted, Decimal(10,-1) and Decimal(20,-5) both cast to double and to float fully natively and match Spark on every value I threw at them. The 999999.9999999999 you measured was real, it was just measured against 36d748f2e, and the merge one commit later fixed it underneath you.
Could you drop those two types from the guard and move them into the "safe casts run natively" pin instead? The cast.md section and the PR description need the same edit, since both currently document a divergence that no longer exists.
The Add and Subtract guards look unnecessary too
The comment on negScaleDecimalRejection says these scale-align by multiplying by 10^|delta|, but I can't find that in the Rust for add or subtract. The wide path computes i256_pow10((max_scale - s1) as u32) at wide_decimal_binary_expr.rs:221, and max_scale >= s1 by construction, so that exponent is never negative. The subsequent rescale is already i16 with sign checks. The narrow path just goes to arrow. The panic you attributed to them really does look like the p1 - s1 as u8 underflow at planner.rs, which this PR already repairs.
Empirically, with your i16 rewrite in and the CometAdd / CometSubtract guards out, v + v, v - v, v + decimal(10,0), v - decimal(10,0) and decimal(10,0) - v all run natively and match Spark. That includes Decimal(38,-5) via the DataFrame API, which is wide enough to take the WideDecimalBinaryExpr branch rather than the narrow arrow one.
Would you consider dropping those two guards along with their getUnsupportedReasons overrides, and pinning them as native regressions instead? These five serdes don't mix in CodegenDispatchFallback, so Unsupported here costs a whole-projection fallback to Spark, and it would be a shame to pay that for a case your own planner.rs fix already handles. If you'd rather keep them for conservatism I don't object, but then the comment should carry the real reason rather than the 10^|delta| one.
There is a second copy of the same underflow
create_modulo_expr at native/spark-expr/src/math_funcs/modulo_expr.rs:161 has the identical max(s1, s2) as u8 + max(p1 - s1 as u8, p2 - s2 as u8) expression that you're rewriting in planner.rs, and it's untouched here. That's why Remainder is the one arithmetic operator that genuinely still needs its guard. I confirmed v % v and v % decimal(10,0) fail with your planner.rs fix in place and the Scala guard out.
Could you apply the same i16 rewrite there? The "turns a landmine into an ordinary error for any future caller" argument you make for planner.rs applies word for word to this line, and leaving one of the two fixed is a strange place to end up. It's also worth re-checking afterwards whether CometRemainder still needs its guard at all, the way it turns out add and subtract don't. I started that experiment and the machine ran out of disk mid-rebuild, so I can't tell you the answer.
Divide is the nastiest of the set and worth describing accurately
At native/spark-expr/src/math_funcs/div.rs:100-101:
let l_exp = ((s2 + s3 + 1) as u32).saturating_sub(s1 as u32);
let r_exp = (s1 as u32).saturating_sub((s2 + s3 + 1) as u32);With s1 = -5, s1 as u32 is 4294967291, so r_exp comes out around 4.29e9. p2 as u32 + r_exp then clears the precision check on line 103 and the BigInt branch evaluates BigInt::from(10).pow(4294967279). That's an unbounded allocation rather than an overflow panic, which is a considerably worse thing to hit in production than a crash. I deliberately didn't run this one.
The CometDivide and CometIntegralDivide guards are definitely needed. Could the comment say this instead of the generic overflow story? And should decimal_div reject a negative scale up front with an error, so that if anything ever does reach it the failure is diagnosable rather than a hang?
The arithmetic test can't tell a working guard from one that never fired
The cast test pins its routing properly with assertCodegenRan. The arithmetic one, "arithmetic on negative-scale decimal falls back and returns correct results", only uses checkSparkAnswer, which passes either way. Given that two of these five guards turn out not to be needed, that test would look identical whether the guard fired or not. Could it use checkSparkAnswerAndFallbackReason with negScaleDecimalArithmeticReason? That makes it assert the thing the PR is actually about.
The two deferred items
I'm fine with consolidation being a follow-up, but could you file the issue and link it here before this merges? Otherwise it won't happen. After this lands there are six scale < 0 sites carrying five different reason strings, counting the two in CometCast, the new one in MathBase, CometRound, and the one CometCeil and CometFloor share at math.scala:45 and :69.
Kernel hardening deserves the same treatment, and nothing tracks it today. 10_i128.pow(scale as u32) is still live in numeric.rs at :466, :508, :543, :569, :757 and :1309, reachable from the Iceberg native scan, a UDF, or any serde added later. One issue covering both is fine by me.
While you're filing, CometRound already routes this exact negative-scale condition through the JVM codegen dispatcher rather than falling back to Spark, which is precisely the gap #5572 is about. A child issue there for the arithmetic serdes would be worth having. Worth noting in it that mixing in CodegenDispatchFallback would also route mathDataTypeSupportLevel's unsupported-datatype case through the dispatcher, so it isn't a one-line change.
Which issue does this PR close?
Closes #5013
Rationale for this change
With
spark.sql.legacy.allowNegativeScaleOfDecimal=true, Spark allowsDecimalType(p, s)withs < 0. Comet's native kernels are not written for that case, in two distinct ways:Overflow. Casts between integer/timestamp types and negative-scale decimals, and decimal
Add/Subtract/Divide/IntegralDivide/Remainder, scale-align by computing10^|scale|(e.g.10_i128.pow(scale as u32)). A negative scale makesas u32produce a huge exponent, so the power computation panics in debug builds and silently wraps in release (overflow-checks = false).Precision loss.
Decimal(neg) -> Float/Doublehas no Comet arm incast.rsand falls through to arrow's kernel, which divides by10^scale. For a negative scale that divisor is not exactly representable, so the result silently diverges from Spark's exactBigDecimal.doubleValue()— aDecimal(20,-5)holding1000000comes back as999999.9999999999.Reproduced on
mainin a debug build:allowNegativeScaleOfDecimalis a rarely-used legacy flag (defaultfalse) and the native kernels don't implement negative-scale-safe rescaling, so this PR detects these cases at planning time and takes them off the native path.What changes are included in this PR?
Casts —
CometCast.scala. ReportsUnsupportedforint -> Decimal(neg)(viacanCastFromByte/Short/Int/Long) and forDecimal(neg) -> int/Float/Double/Timestamp(viacanCastFromDecimal, which now takes the sourceDecimalTypeso it can checkfromType.scale).Note that
CometCastmixes inCodegenDispatchFallback, soUnsupportedhere does not fall the projection back to Spark — it routes through the JVM codegen dispatcher and stays in the Comet pipeline. That path is safe for negative scale: it runs Spark's owndoGenCodeoverDecimal/BigDecimal, and Arrow carries the unscaled value with the scale as metadata, so neither the overflow nor the inexact-divisor problem is reachable. The tests assert both that the dispatcher ran and that results match Spark.All other directions to and from negative-scale decimals still run natively —
Stringboth ways,Boolean, and widening to anotherDecimalType— and are pinned by a regression test.Arithmetic —
arithmetic.scala.negScaleDecimalRejection(expr: BinaryArithmetic)inMathBasereturnsUnsupportedwhen either operand or the result is a negative-scale decimal, wired intoCometAdd,CometSubtract,CometDivide,CometIntegralDivideandCometRemainder. These five do not mix inCodegenDispatchFallback, so hereUnsupportedis a genuine Spark fallback. Each also overridesgetUnsupportedReasons()so the generatedmath.mdcarries the caveat.CometMultiplyandUnaryMinusare intentionally left unguarded — neither scale-aligns.Native —
planner.rs. The decimal width test computedmax(s1, s2) as u8 + max(p1 - s1 as u8, p2 - s2 as u8); with a negative scales as u8wraps to 255 and the subtraction underflows. Rewritten ini16. The Scala guards mean this is no longer reachable from the Spark planner, but it removes the landmine for other callers.Docs. New
_category_template/cast.mdsection covering these pairs, since the generated matrix only samplescreateDecimalType(10, 2).How are these changes tested?
New and updated tests in
CometNativeCastSuiteandCometExpressionSuite, parameterised over bothDecimalType(10, -1)andDecimalType(20, -5). The wider type matters: combined operands crossDECIMAL128_MAX_PRECISION, selectingWideDecimalBinaryExpranddecimal_div's BigInt path rather than the narrow arrow kernels. It also pinsCometMultiplyagainst a negative output scale —Decimal(20,-5) * Decimal(20,-5)yieldsDecimal(38,-10).Verified locally against a debug native build, where the panic surfaces:
Note: the
TimestampandFloat/Doublecases are not in the linked issue's reproducer. Both were found while auditing the same code paths, share the root cause (no negative-scale-safe rescaling natively), and are included here. The float/double divergence was caught by parameterising overDecimalType(20, -5), per review feedback.