use super::arrow_convert::{
bigint_to_i128, ArrowConvertError, DECIMAL_FIXED_SCALE, DECIMAL_MAX_PRECISION,
};
use num_bigint::BigInt;
use std::sync::LazyLock;
static DECIMAL128_MAX_ABS: LazyLock<BigInt> =
LazyLock::new(|| BigInt::from(10i64).pow(38u32) - BigInt::from(1i64));
pub(crate) fn rescale_decimal(scale: i32, unscaled: &[u8]) -> Result<i128, ArrowConvertError> {
if unscaled.is_empty() {
return Ok(0i128);
}
if scale > DECIMAL_FIXED_SCALE {
return Err(ArrowConvertError::InvalidValue(format!(
"decimal scale {scale} exceeds the fixed export scale {DECIMAL_FIXED_SCALE}; \
refusing to truncate (would lose precision)"
)));
}
let bigint = BigInt::from_signed_bytes_be(unscaled);
let delta = DECIMAL_FIXED_SCALE as i64 - scale as i64;
let rescaled = if delta == 0 || bigint.sign() == num_bigint::Sign::NoSign {
bigint
} else if delta > DECIMAL_MAX_PRECISION as i64 {
return Err(ArrowConvertError::InvalidValue(format!(
"decimal scale {scale} requires rescaling by 10^{delta}, which exceeds \
Decimal128(38, {DECIMAL_FIXED_SCALE}) range"
)));
} else {
let factor = BigInt::from(10i64).pow(delta as u32);
bigint * factor
};
let abs_rescaled = if rescaled.sign() == num_bigint::Sign::Minus {
-rescaled.clone()
} else {
rescaled.clone()
};
if abs_rescaled > *DECIMAL128_MAX_ABS {
return Err(ArrowConvertError::InvalidValue(format!(
"Decimal value exceeds Decimal128(38, {DECIMAL_FIXED_SCALE}) range after rescaling"
)));
}
bigint_to_i128(&rescaled)
}
#[cfg(all(test, feature = "parquet"))]
mod tests {
use crate::export::parquet::{ParquetExportOptions, ParquetWriter, StreamingParquetWriter};
use crate::query::{ColumnInfo, QueryMetadata, QueryResult, QueryRow};
use crate::schema::CqlType;
use crate::types::DataType;
use crate::{RowKey, Value};
use std::collections::HashMap;
use std::sync::mpsc;
use std::time::Duration;
const WATCHDOG: Duration = Duration::from_secs(15);
fn decimal_metadata() -> QueryMetadata {
QueryMetadata {
columns: vec![ColumnInfo {
name: "account_balance".to_string(),
data_type: DataType::Blob,
nullable: true,
position: 0,
table_name: None,
cql_type: Some(CqlType::Decimal),
}],
..Default::default()
}
}
fn row_with_decimal(scale: i32, unscaled: Vec<u8>) -> QueryRow {
let mut values = HashMap::new();
values.insert(
"account_balance".to_string(),
Value::Decimal { scale, unscaled },
);
QueryRow::with_values(RowKey::new(vec![1]), values)
}
fn run_bounded<T: Send + 'static>(what: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(WATCHDOG) {
Ok(v) => v,
Err(mpsc::RecvTimeoutError::Disconnected) => panic!(
"issue #1755: {what} worker thread panicked without producing a result \
(channel disconnected)"
),
Err(mpsc::RecvTimeoutError::Timeout) => panic!(
"issue #1755: {what} did not terminate within {WATCHDOG:?} (hang) — \
worker thread intentionally leaked (see run_bounded doc comment)"
),
}
}
#[test]
fn batch_export_garbage_decimal_scale_errors_not_hangs() {
let mut result = QueryResult::new();
result.metadata = decimal_metadata();
result
.rows
.push(row_with_decimal(-1_000_000_000, vec![0x01]));
let is_err = run_bounded("ParquetWriter::write", move || {
ParquetWriter::write(&result, &ParquetExportOptions::default()).is_err()
});
assert!(
is_err,
"a garbage decimal scale must produce a typed ParquetExportError, not silent success"
);
}
#[test]
fn streaming_export_garbage_decimal_scale_errors_not_hangs() {
let metadata = decimal_metadata();
let row = row_with_decimal(-1_000_000_000, vec![0x01]);
let is_err = run_bounded("StreamingParquetWriter export", move || {
let opts = ParquetExportOptions::default();
let mut writer = match StreamingParquetWriter::new(Vec::<u8>::new(), &metadata, &opts) {
Ok(w) => w,
Err(_) => return true, };
writer.write_chunk(std::slice::from_ref(&row)).is_err() || writer.finalize().is_err()
});
assert!(
is_err,
"streaming export must return a typed error on a garbage decimal scale, not hang"
);
}
#[test]
fn export_i32_min_decimal_scale_is_bounded() {
let mut result = QueryResult::new();
result.metadata = decimal_metadata();
result
.rows
.push(row_with_decimal(i32::MIN, vec![0x7f, 0xff]));
let is_err = run_bounded("ParquetWriter::write (i32::MIN scale)", move || {
ParquetWriter::write(&result, &ParquetExportOptions::default()).is_err()
});
assert!(
is_err,
"i32::MIN scale must fail closed without overflow/hang"
);
}
#[test]
fn export_zero_unscaled_with_extreme_scale_succeeds() {
let mut result = QueryResult::new();
result.metadata = decimal_metadata();
result
.rows
.push(row_with_decimal(-1_000_000_000, vec![0x00]));
let bytes = run_bounded("ParquetWriter::write (zero unscaled)", move || {
ParquetWriter::write(&result, &ParquetExportOptions::default())
})
.expect("a zero-valued decimal is representable at any scale and must export");
assert_eq!(&bytes[0..4], b"PAR1", "valid Parquet output expected");
}
#[test]
fn export_valid_decimal_still_succeeds() {
let mut result = QueryResult::new();
result.metadata = decimal_metadata();
result.rows.push(row_with_decimal(2, vec![0x30, 0x39]));
let bytes = run_bounded("ParquetWriter::write (valid decimal)", move || {
ParquetWriter::write(&result, &ParquetExportOptions::default())
})
.expect("a valid in-range decimal must still export after the #1755 bound");
assert_eq!(&bytes[0..4], b"PAR1", "valid Parquet output expected");
}
}