Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 2.4k
fix: Spark-compatible HALF_UP rounding for round() on float types#22813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -15,6 +15,7 @@ | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
| use std::str::FromStr; | ||
| use std::sync::Arc; | ||
| use arrow::array::*; | ||
| @@ -23,6 +24,8 @@ use arrow::datatypes::{ | ||
| Decimal256Type, Float16Type, Float32Type, Float64Type, Int8Type, Int16Type, | ||
| Int32Type, Int64Type, UInt8Type, UInt16Type, UInt32Type, UInt64Type, | ||
| }; | ||
| use bigdecimal::num_traits::ToPrimitive; | ||
| use bigdecimal::{BigDecimal, RoundingMode}; | ||
| use datafusion_common::types::{ | ||
| NativeType, logical_float32, logical_float64, logical_int32, | ||
| }; | ||
| @@ -187,20 +190,43 @@ fn get_scale(args: &[ColumnarValue]) -> Result<Option<i32>> { | ||
| /// round_float(125.0, -1) → 130.0 | ||
| /// ``` | ||
| fn round_float<T: num_traits::Float>(value: T, scale: i32) -> T { | ||
| if scale >= 0 { | ||
| let factor = T::from(10.0f64.powi(scale)).unwrap_or_else(T::infinity); | ||
| if factor.is_infinite() { | ||
| // Very large positive scale — value is already precise enough, return as-is | ||
| return value; | ||
| } | ||
| (value * factor).round() / factor | ||
| } else { | ||
| let factor = T::from(10.0f64.powi(-scale)).unwrap_or_else(T::infinity); | ||
| if factor.is_infinite() { | ||
| // Very large negative scale — any finite value rounds to 0 | ||
| return T::zero(); | ||
| } | ||
| (value / factor).round() * factor | ||
| // Widen to f64 first. For f32 inputs this matches Spark's `f.toDouble` | ||
| // step (FloatType: `BigDecimal(f.toDouble).setScale(..).toFloat`), which | ||
| // exposes the binary-float error before rounding. For f64 it is a no-op. | ||
| let Some(d) = value.to_f64() else { | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. appreciate if we can name vars more meaningfully than | ||
| return value; | ||
| }; | ||
| // Spark returns NaN / ±Inf unchanged; BigDecimal cannot represent them. | ||
| if !d.is_finite() { | ||
| return value; | ||
| } | ||
| // `d.to_string()` produces the shortest round-trip decimal string, matching | ||
| // Scala's `BigDecimal(d) = java.math.BigDecimal.valueOf(d)` semantics. So | ||
| // `round(1.255_f64, 2)` parses "1.255" and rounds to 1.26 (not the naive | ||
| // binary-float 1.25). | ||
| let Ok(bd) = BigDecimal::from_str(&d.to_string()) else { | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we've already guarded against non-finite Returning the original value here makes that invariant a little less explicit and could potentially hide a future regression. Would it make sense to encode the assumption directly with something like: let bd = BigDecimal::from_str(&d.to_string()).expect("finite f64 Display parses as BigDecimal");Alternatively, a | ||
| // Should not happen for a finite f64, but fall back gracefully. | ||
| return value; | ||
| }; | ||
Comment on lines
+205
to
+212
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. something i find interesting is apparently the spark code for this differs a bit. for caseDoubleType=>vald= input1.asInstanceOf[Double]
if (d.isNaN || d.isInfinite) {
d
} else {
BigDecimal(d).setScale(_scale, mode).toDouble
}
meanwhile for caseDoubleType=>// if child eval to NaN or Infinity, just return it.s""" if (Double.isNaN(${ce.value}) || Double.isInfinite(${ce.value})) {${ev.value} = ${ce.value}; } else {${ev.value} = java.math.BigDecimal.valueOf(${ce.value}). setScale(${_scale}, java.math.BigDecimal.${modeStr}).doubleValue(); }"""
do we need to consider this? | ||
| // A finite f64 carries at most ~324 fractional decimal digits and saturates | ||
| // below ~1e309 in magnitude, so any `scale` past those bounds is already a | ||
| // no-op (large positive) or collapses the value to zero (large negative). | ||
| // Clamp before `with_scale_round` so adversarial input such as | ||
| // `round(x, i32::MAX)` cannot drive an unbounded `10^scale` BigInt | ||
| // allocation. The clamp is exact for every finite f64. | ||
| let clamped_scale = i64::from(scale).clamp(-340, 340); | ||
Comment on lines
+214
to
+221
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We might need to error if following Spark semantics here? >>>spark.version'4.1.2'>>>spark.sql("select round(1.255::double, 2147483647)").show()
Traceback (mostrecentcalllast):
File"<python-input-4>", line1, in<module>spark.sql("select round(1.255::double, 2147483647)").show()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^File"/Users/jeffrey/.cache/uv/archive-v0/GIQgMkXRrHZBaiUVcMOta/lib/python3.13/site-packages/pyspark/sql/classic/dataframe.py", line285, inshowprint(self._show_string(n, truncate, vertical))
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^File"/Users/jeffrey/.cache/uv/archive-v0/GIQgMkXRrHZBaiUVcMOta/lib/python3.13/site-packages/pyspark/sql/classic/dataframe.py", line303, in_show_stringreturnself._jdf.showString(n, 20, vertical)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^File"/Users/jeffrey/.cache/uv/archive-v0/GIQgMkXRrHZBaiUVcMOta/lib/python3.13/site-packages/py4j/java_gateway.py", line1362, in__call__return_value=get_return_value(
answer, self.gateway_client, self.target_id, self.name)
File"/Users/jeffrey/.cache/uv/archive-v0/GIQgMkXRrHZBaiUVcMOta/lib/python3.13/site-packages/pyspark/errors/exceptions/captured.py", line269, indecoraiseconvertedfromNonepyspark.errors.exceptions.captured.ArithmeticException: BigIntegerwouldoverflowsupportedrangeContributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good observation, Spark 4.1.2 has ANSI mode ON by default, in Datafusion we just started to support it. | ||
| // HALF_UP == ties away from zero, handles negative `scale` directly | ||
| // (e.g. scale -1 rounds to the nearest ten). | ||
| let rounded = bd.with_scale_round(clamped_scale, RoundingMode::HalfUp); | ||
| match rounded.to_f64() { | ||
| // For T = f32 this is the `.toFloat` narrowing; for f64 the `.toDouble`. | ||
| Some(out) => T::from(out).unwrap_or(value), | ||
| None => value, | ||
| } | ||
| } | ||
| @@ -652,3 +678,76 @@ fn spark_round(args: &[ColumnarValue], enable_ansi_mode: bool) -> Result<Columna | ||
| }, | ||
| } | ||
| } | ||
| #[cfg(test)] | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SLT tests should be enough. We keep rust tests for SQL functions only if SLT coverage is not sufficient and SLT constraints full test coverage | ||
| mod tests { | ||
| use super::*; | ||
| #[test] | ||
| fn test_round_float_halfup_double() { | ||
| // The core bug: naive binary-float rounding gives 1.25 here, but Spark's | ||
| // BigDecimal(valueOf) approach parses the shortest repr "1.255" → 1.26. | ||
| assert_eq!(round_float(1.255_f64, 2), 1.26_f64); | ||
| assert_eq!(round_float(1.005_f64, 2), 1.01_f64); | ||
| } | ||
| #[test] | ||
| fn test_round_float_double_regression_guards() { | ||
| // These already worked before the fix; guard against regressions. | ||
| assert_eq!(round_float(2.675_f64, 2), 2.68_f64); | ||
| assert_eq!(round_float(8.35_f64, 1), 8.4_f64); | ||
| } | ||
| #[test] | ||
| fn test_round_float_negative_scale() { | ||
| assert_eq!(round_float(125.0_f64, -1), 130.0_f64); | ||
| assert_eq!(round_float(1234.0_f64, -2), 1200.0_f64); | ||
| } | ||
| #[test] | ||
| fn test_round_float_float32_widening() { | ||
| // Spark FloatType widens f32→f64 first: 1.255f.toDouble == 1.2549999952316284, | ||
| // whose shortest string rounds to 1.25 (NOT 1.26). | ||
| assert_eq!(round_float(1.255_f32, 2), 1.25_f32); | ||
| } | ||
| #[test] | ||
| fn test_round_float_nan_inf_passthrough() { | ||
| assert!(round_float(f64::NAN, 2).is_nan()); | ||
| assert!(round_float(f64::INFINITY, 2).is_infinite()); | ||
| assert!(round_float(f64::NEG_INFINITY, 2).is_infinite()); | ||
| } | ||
| #[test] | ||
| fn test_round_float_ties_away_from_zero() { | ||
| assert_eq!(round_float(2.5_f64, 0), 3.0_f64); | ||
| assert_eq!(round_float(-2.5_f64, 0), -3.0_f64); | ||
| } | ||
| #[test] | ||
| fn test_round_float_negative_values() { | ||
| // Negative value with positive scale — symmetric to the positive case. | ||
| assert_eq!(round_float(-1.255_f64, 2), -1.26_f64); | ||
| assert_eq!(round_float(-1.005_f64, 2), -1.01_f64); | ||
| assert_eq!(round_float(-1.255_f32, 2), -1.25_f32); | ||
| } | ||
| #[test] | ||
| fn test_round_float_zero_and_default_scale() { | ||
| assert_eq!(round_float(0.0_f64, 2), 0.0_f64); | ||
| assert_eq!(round_float(-0.0_f64, 2), 0.0_f64); | ||
| // Default scale 0 truncating a fraction. | ||
| assert_eq!(round_float(1.4_f64, 0), 1.0_f64); | ||
| assert_eq!(round_float(1.5_f64, 0), 2.0_f64); | ||
| } | ||
| #[test] | ||
| fn test_round_float_extreme_scales_are_bounded() { | ||
| // Adversarial scales must not allocate an unbounded 10^scale BigInt. | ||
| // Large positive scale is a no-op; large negative collapses to zero. | ||
| assert_eq!(round_float(1.255_f64, i32::MAX), 1.255_f64); | ||
| assert_eq!(round_float(1.255_f64, i32::MIN), 0.0_f64); | ||
| assert_eq!(round_float(f64::MAX, i32::MIN), 0.0_f64); | ||
| assert_eq!(round_float(123.456_f64, 1000), 123.456_f64); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we could also do it like so