Skip to content

Commit 292ff08

Browse files
fix(vortex-row): decimal sort keys are not memcmp-comparable across chunks (#8937)
## The bug `ORDER BY` / top-k on a decimal column silently returns wrongly ordered rows whenever the column spans chunks whose physical value widths differ. No error is raised; the output looks plausible. ### Example Take a `DECIMAL(7, 5)` tip column and `ORDER BY tip DESC`. Two rows land in different chunks, and compression picks a different physical width for each chunk: | row | unscaled value | chunk's physical type | |---|---|---| | `tip = 0.00484` | `484` | **i16** (484 doesn't fit i8) | | `tip = 0.00091` | `91` | **i8** (every value in its chunk fits i8) | A descending sort key is built per chunk: write the value big-endian, flip the sign bit, invert the value bytes (for DESC), and prefix the non-null sentinel `0xFE`. Before this fix, the number of value bytes came from the *chunk's* type: ``` tip = 0.00484, encoded at its chunk's width (i16 → 2 value bytes) big-endian bytes 01 E4 flip sign bit 81 E4 invert for DESC 7E 1B prepend sentinel FE 7E 1B ← 3-byte key tip = 0.00091, encoded at its chunk's width (i8 → 1 value byte) big-endian bytes 5B flip sign bit DB invert for DESC 24 prepend sentinel FE 24 ← 2-byte key ``` The sorter compares keys with `memcmp`: ``` key(0.00484) = FE 7E 1B key(0.00091) = FE 24 ─┬ ─┬ │ └─ byte 1 decides: 0x24 < 0x7E, so key(0.00091) is the smaller key └─ byte 0: equal ``` But byte 1 means different things in the two keys: in the 3-byte key it is the *high-order* byte of a two-byte number, while in the 2-byte key it is the *only* byte of a one-byte number. The comparison is meaningless — and DESC encoding promises **bigger value ⇒ smaller key**, so the smaller key wins: the sorter ranks `0.00091` as a larger tip than `0.00484`. Ascending breaks symmetrically. With this fix, both chunks encode at the width the *declared* dtype implies (`DECIMAL(7,5)` → i32 → 4 value bytes), whatever their physical storage: ``` key(0.00484) = FE 7F FF FE 1B key(0.00091) = FE 7F FF FF A4 ─┬ └─ byte 3 decides: 0xFE < 0xFF, so key(0.00484) is smaller ``` Equal-length keys, every byte position aligned — `0.00484` correctly sorts first in the descending order. ## The fix Derive the key width from the declared dtype, so every chunk of a column encodes identical-length keys: - `decimal_key_type` (vortex-row): the dtype-derived width, used by both the sizing pass and the encode dispatch. - `converted_buffer<W>` (vortex-array, next to `widened_buffer`): returns a chunk's values at exactly width `W`. Zero-copy when the chunk is already stored at `W` (the common case), lossless widening otherwise, and an error for any value that violates its declared precision, such values previously encoded a garbage key silently. The encode loop itself is unchanged; only where it reads its values from changed. --------- Signed-off-by: Nemo Yu <zyu379@wisc.edu> Co-authored-by: Robert Kruszewski <github@robertk.io>
1 parent d9619b8 commit 292ff08

3 files changed

Lines changed: 157 additions & 6 deletions

File tree

vortex-array/src/arrays/decimal/utils.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use itertools::Itertools;
55
use itertools::MinMaxResult;
66
use vortex_buffer::Buffer;
77
use vortex_error::VortexExpect;
8+
use vortex_error::VortexResult;
9+
use vortex_error::vortex_bail;
10+
use vortex_mask::Mask;
811

912
use crate::arrays::DecimalArray;
1013
use crate::arrays::decimal::DecimalArrayExt;
@@ -28,6 +31,63 @@ pub(crate) fn widened_buffer<W: NativeDecimalType>(array: &DecimalArray) -> Buff
2831
})
2932
}
3033

34+
/// Return the array's unscaled values converted to exactly `W`, whatever the array's
35+
/// storage type. Zero-copy when the array is already stored at `W`.
36+
///
37+
/// Widening is lossless. Narrowing fails for any *valid* value that does not fit `W`.
38+
/// Null slots may hold arbitrary bytes and never fail; their contents in the returned
39+
/// buffer are likewise arbitrary and must not be read.
40+
pub fn converted_buffer<W: NativeDecimalType>(
41+
array: &DecimalArray,
42+
validity: &Mask,
43+
) -> VortexResult<Buffer<W>> {
44+
// Widening can never fail, so it needs no validation pass.
45+
if array.values_type() <= W::DECIMAL_TYPE {
46+
return Ok(widened_buffer(array));
47+
}
48+
match_each_decimal_value_type!(array.values_type(), |T| {
49+
let src = array.buffer::<T>();
50+
match validity {
51+
Mask::AllTrue(_) => {
52+
// Keeping the overflow scan branchless and vectorizable. Only on overflow
53+
// do we rescan for diagnostics.
54+
let any_overflow = src.iter().fold(false, |acc, v| acc | W::from(*v).is_none());
55+
if any_overflow {
56+
let (i, v) = src
57+
.iter()
58+
.enumerate()
59+
.find(|&(_, v)| W::from(*v).is_none())
60+
.vortex_expect("overflow scan found an overflowing value");
61+
vortex_bail!(
62+
"decimal value {v} at index {i} does not fit {}",
63+
W::DECIMAL_TYPE
64+
);
65+
}
66+
}
67+
Mask::AllFalse(_) => return Ok(Buffer::zeroed(src.len())),
68+
Mask::Values(values) => {
69+
let any_overflow = src.iter().fold(false, |acc, v| acc | W::from(*v).is_none());
70+
if any_overflow {
71+
for (i, v) in src.iter().enumerate() {
72+
if values.value(i) && W::from(*v).is_none() {
73+
vortex_bail!(
74+
"decimal value {v} at index {i} does not fit {}",
75+
W::DECIMAL_TYPE
76+
);
77+
}
78+
}
79+
}
80+
}
81+
}
82+
// The convert pass is infallible: every valid value fits, and out-of-range null-slot
83+
// garbage is mapped to the default value. Callers must still ignore null slots.
84+
Ok(src
85+
.iter()
86+
.map(|v| W::from(*v).unwrap_or_default())
87+
.collect())
88+
})
89+
}
90+
3191
macro_rules! try_downcast {
3292
($array:expr, from: $src:ty, to: $($dst:ty),*) => {{
3393
use crate::dtype::BigCast;

vortex-row/src/codec.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ use vortex_array::arrays::NullArray;
3333
use vortex_array::arrays::PrimitiveArray;
3434
use vortex_array::arrays::StructArray;
3535
use vortex_array::arrays::VarBinViewArray;
36+
use vortex_array::arrays::decimal::DecimalArrayExt;
37+
use vortex_array::arrays::decimal::converted_buffer;
3638
use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt;
3739
use vortex_array::arrays::fixed_size_list::FixedSizeListArraySlotsExt;
3840
use vortex_array::arrays::struct_::StructArrayExt;
3941
use vortex_array::dtype::DType;
42+
use vortex_array::dtype::DecimalDType;
4043
use vortex_array::dtype::DecimalType;
4144
use vortex_array::dtype::NativePType;
4245
use vortex_array::dtype::half::f16;
@@ -183,7 +186,7 @@ pub(crate) fn row_width_for_dtype(dtype: &DType) -> VortexResult<RowWidth> {
183186
ptype.byte_width(),
184187
)))),
185188
DType::Decimal(dt, _) => {
186-
let vt = DecimalType::smallest_decimal_value_type(dt);
189+
let vt = decimal_key_type(dt);
187190
if matches!(vt, DecimalType::I256) {
188191
vortex_bail!("row encoding for Decimal256 is not yet implemented");
189192
}
@@ -385,10 +388,19 @@ fn add_size_primitive(arr: &PrimitiveArray, sizes: &mut [u32]) {
385388
}
386389

387390
fn add_size_decimal(arr: &DecimalArray, sizes: &mut [u32]) {
388-
let width = byte_width_u32(arr.values_type().byte_width());
391+
let width = byte_width_u32(decimal_key_type(&arr.decimal_dtype()).byte_width());
389392
add_size_const(sizes, encoded_size_for_fixed(width));
390393
}
391394

395+
/// The decimal type every chunk of a decimal column encodes its keys at.
396+
///
397+
/// Derived from the declared decimal dtype rather than the chunk's physical `values_type`,
398+
/// so keys from differently compressed chunks stay memcmp-comparable. Both the size plan
399+
/// ([`row_width_for_dtype`]) and the encoder derive their width from here.
400+
fn decimal_key_type(dt: &DecimalDType) -> DecimalType {
401+
DecimalType::smallest_decimal_value_type(dt)
402+
}
403+
392404
fn add_size_varbinview(
393405
arr: &VarBinViewArray,
394406
sizes: &mut [u32],
@@ -634,7 +646,7 @@ fn encode_decimal(
634646
ctx: &mut ExecutionCtx,
635647
) -> VortexResult<()> {
636648
let mask = arr.as_ref().validity()?.execute_mask(arr.len(), ctx)?;
637-
match arr.values_type() {
649+
match decimal_key_type(&arr.decimal_dtype()) {
638650
DecimalType::I8 => {
639651
encode_decimal_typed::<i8>(arr, &mask, field, row_offsets, col_offset, out)
640652
}
@@ -654,7 +666,6 @@ fn encode_decimal(
654666
vortex_bail!("row encoding for Decimal256 is not yet implemented")
655667
}
656668
}
657-
Ok(())
658669
}
659670

660671
fn encode_decimal_typed<T>(
@@ -664,14 +675,15 @@ fn encode_decimal_typed<T>(
664675
row_offsets: &[u32],
665676
col_offset: &mut [u32],
666677
out: &mut [u8],
667-
) where
678+
) -> VortexResult<()>
679+
where
668680
T: vortex_array::dtype::NativeDecimalType + RowEncode,
669681
{
670682
let non_null = field.non_null_sentinel();
671683
let null = field.null_sentinel();
672684
let value_bytes = size_of::<T>();
673685
let total = encoded_size_for_fixed(byte_width_u32(value_bytes));
674-
let slice = arr.buffer::<T>();
686+
let slice = converted_buffer::<T>(arr, mask)?;
675687
for i in 0..slice.len() {
676688
let pos = (row_offsets[i] + col_offset[i]) as usize;
677689
if mask.value(i) {
@@ -685,6 +697,7 @@ fn encode_decimal_typed<T>(
685697
}
686698
col_offset[i] += total;
687699
}
700+
Ok(())
688701
}
689702

690703
fn encode_varbinview(

vortex-row/src/tests.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ use vortex_array::IntoArray;
1010
use vortex_array::VortexSessionExecute;
1111
use vortex_array::array_session;
1212
use vortex_array::arrays::BoolArray;
13+
use vortex_array::arrays::DecimalArray;
1314
use vortex_array::arrays::ExtensionArray;
1415
use vortex_array::arrays::ListViewArray;
1516
use vortex_array::arrays::PrimitiveArray;
1617
use vortex_array::arrays::StructArray;
1718
use vortex_array::arrays::VarBinViewArray;
1819
use vortex_array::arrays::listview::ListViewArrayExt;
1920
use vortex_array::arrays::listview::ListViewArraySlotsExt;
21+
use vortex_array::dtype::DecimalDType;
2022
use vortex_array::dtype::Nullability;
2123
use vortex_array::extension::datetime::Date;
2224
use vortex_array::extension::datetime::TimeUnit;
25+
use vortex_array::validity::Validity;
26+
use vortex_buffer::buffer;
2327
use vortex_error::VortexResult;
2428

2529
use crate::RowEncoder;
@@ -614,3 +618,77 @@ fn reject_list_dtype_early() {
614618
"expected error mentioning List, got: {err}"
615619
);
616620
}
621+
622+
/// Chunks of one decimal column can compress to different physical value widths. The key
623+
/// width must come from the declared dtype, not the chunk's `values_type`, otherwise keys
624+
/// from different chunks are not memcmp-comparable.
625+
#[rstest]
626+
#[case::ascending(false)]
627+
#[case::descending(true)]
628+
fn decimal_keys_comparable_across_chunk_widths(#[case] descending: bool) -> VortexResult<()> {
629+
let mut ctx = array_session().create_execution_ctx();
630+
let dtype = DecimalDType::new(7, 5);
631+
let field = RowSortField::new(descending, true);
632+
633+
// One logical DECIMAL(7, 5) column whose chunks compressed to different physical widths.
634+
let chunk_i8 = DecimalArray::new(buffer![91i8, -5], dtype, Validity::NonNullable).into_array();
635+
let chunk_i16 =
636+
DecimalArray::new(buffer![484i16, 300], dtype, Validity::NonNullable).into_array();
637+
let values: [i32; 4] = [91, -5, 484, 300];
638+
639+
let keys_i8 = collect_row_bytes(&convert_columns(&[chunk_i8], &[field], &mut ctx)?);
640+
let keys_i16 = collect_row_bytes(&convert_columns(&[chunk_i16], &[field], &mut ctx)?);
641+
let keys = [keys_i8, keys_i16].concat();
642+
643+
for (i, vi) in values.iter().enumerate() {
644+
for (j, vj) in values.iter().enumerate() {
645+
let expected = if descending { vj.cmp(vi) } else { vi.cmp(vj) };
646+
assert_eq!(
647+
keys[i].cmp(&keys[j]),
648+
expected,
649+
"keys for {vi} and {vj} do not compare like the values"
650+
);
651+
}
652+
}
653+
Ok(())
654+
}
655+
656+
/// A null slot's backing value is unspecified and might not fit the dtype-derived key width;
657+
/// encoding must ignore it rather than report a spurious overflow.
658+
#[test]
659+
fn decimal_null_slot_garbage_does_not_error() -> VortexResult<()> {
660+
let mut ctx = array_session().create_execution_ctx();
661+
let dtype = DecimalDType::new(7, 5);
662+
let field = RowSortField::new(false, true);
663+
664+
let chunk = DecimalArray::new(
665+
buffer![484i64, 10_000_000_000_000, 91],
666+
dtype,
667+
Validity::from_iter([true, false, true]),
668+
)
669+
.into_array();
670+
671+
let keys = collect_row_bytes(&convert_columns(&[chunk], &[field], &mut ctx)?);
672+
assert!(keys[1] < keys[2], "null must sort before non-nulls");
673+
assert!(keys[2] < keys[0], "91 must sort before 484");
674+
Ok(())
675+
}
676+
677+
/// A valid value too large for the dtype-derived key width must fail loudly instead of
678+
/// silently encoding a corrupt key.
679+
#[test]
680+
fn decimal_value_not_fitting_key_width_errors() {
681+
let mut ctx = array_session().create_execution_ctx();
682+
let dtype = DecimalDType::new(7, 5);
683+
let field = RowSortField::new(false, true);
684+
685+
let chunk = DecimalArray::new(buffer![10_000_000_000_000i64], dtype, Validity::NonNullable)
686+
.into_array();
687+
688+
let err = convert_columns(&[chunk], &[field], &mut ctx)
689+
.expect_err("a valid value wider than the key width must be rejected");
690+
assert!(
691+
err.to_string().contains("does not fit"),
692+
"expected a does-not-fit error, got: {err}"
693+
);
694+
}

0 commit comments

Comments
 (0)