From 0b15b64ce26f0776e23d090dbb04831df891cbcf Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Tue, 20 Oct 2026 09:19:44 +0800 Subject: [PATCH 1/2] Return an empty array from fill_null on an empty input Filling nulls on a chunked array slices the constant fill to each chunk, and slicing to an empty range returns an empty canonical array rather than a constant. Executing fill_null on an empty chunk then failed with "fill_null fill_value must be a constant/scalar", which the array_ops fuzzer hit by filling a chunked array with empty chunks more than once. An empty input has no nulls to fill, so return an empty array of the result dtype before asking for a scalar fill. Closes #10626 Signed-off-by: Vincent <1729065730@qq.com> --- .../src/scalar_fn/fns/fill_null/mod.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/vortex-array/src/scalar_fn/fns/fill_null/mod.rs b/vortex-array/src/scalar_fn/fns/fill_null/mod.rs index 678ddc4a8a4..ad32e6ec0bc 100644 --- a/vortex-array/src/scalar_fn/fns/fill_null/mod.rs +++ b/vortex-array/src/scalar_fn/fns/fill_null/mod.rs @@ -13,6 +13,7 @@ use vortex_session::registry::CachedId; use crate::AnyColumnar; use crate::ArrayRef; +use crate::Canonical; use crate::CanonicalView; use crate::ColumnarView; use crate::ExecutionCtx; @@ -122,6 +123,14 @@ impl ScalarFnVTable for FillNull { ) -> VortexResult { let input = args.get(0)?; let fill_value = args.get(1)?; + // An empty input has no nulls to fill. Slicing a constant fill to an empty range yields + // an empty canonical array rather than a constant, so return before requiring a scalar. + if input.is_empty() { + let dtype = input + .dtype() + .with_nullability(fill_value.dtype().nullability()); + return Ok(Canonical::empty(&dtype).into_array()); + } // Parent kernels decline a fill that is not already constant. Materialize it here so a // lazy scalar, such as a literal `ScalarFnArray`, can still fill nulls. let fill_was_lazy = !fill_value.is::(); @@ -403,6 +412,46 @@ mod tests { Ok(()) } + #[test] + fn empty_input_ignores_non_constant_fill() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let input = PrimitiveArray::from_option_iter(Vec::>::new()).into_array(); + let fill = PrimitiveArray::from_iter(Vec::::new()).into_array(); + + let result = FillNull::try_new(input, fill)? + .into_array() + .execute::(&mut ctx)?; + + assert_eq!( + result.dtype(), + &DType::Primitive(PType::I32, Nullability::NonNullable) + ); + assert!(result.is_empty()); + Ok(()) + } + + /// Filling a chunked array slices the constant fill per chunk, and an empty slice is a + /// canonical array rather than a constant. Stacked fills over an empty chunk must still run. + #[test] + fn repeated_fill_over_empty_chunk() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let input = ChunkedArray::try_new( + vec![ + PrimitiveArray::from_option_iter(Vec::>::new()).into_array(), + PrimitiveArray::from_option_iter([Some(7u16), None]).into_array(), + ], + DType::Primitive(PType::U16, Nullability::Nullable), + )? + .into_array(); + + let result = input + .fill_null(Scalar::primitive(1u16, Nullability::Nullable))? + .fill_null(Scalar::primitive(2u16, Nullability::NonNullable))?; + + assert_arrays_eq!(result, PrimitiveArray::from_iter([7u16, 1]), &mut ctx); + Ok(()) + } + #[test] fn evaluate_struct_field() { let mut ctx = array_session().create_execution_ctx(); From 25d87fe7b9096d5635b1c03b860899e36e70bb9a Mon Sep 17 00:00:00 2001 From: Vincent <1729065730@qq.com> Date: Tue, 20 Oct 2026 21:21:57 +0800 Subject: [PATCH 2/2] Check the fill value before the empty-input return in fill_null Run the constant and non-null fill checks first, and only return an empty array for an empty input when the fill is not a constant (an empty slice of a constant fill). Drop empty_input_ignores_non_constant_fill, since repeated_fill_over_empty_chunk covers the case the fuzzer hit. Signed-off-by: Vincent <1729065730@qq.com> --- .../src/scalar_fn/fns/fill_null/mod.rs | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/vortex-array/src/scalar_fn/fns/fill_null/mod.rs b/vortex-array/src/scalar_fn/fns/fill_null/mod.rs index ad32e6ec0bc..c5a0a80423e 100644 --- a/vortex-array/src/scalar_fn/fns/fill_null/mod.rs +++ b/vortex-array/src/scalar_fn/fns/fill_null/mod.rs @@ -123,14 +123,6 @@ impl ScalarFnVTable for FillNull { ) -> VortexResult { let input = args.get(0)?; let fill_value = args.get(1)?; - // An empty input has no nulls to fill. Slicing a constant fill to an empty range yields - // an empty canonical array rather than a constant, so return before requiring a scalar. - if input.is_empty() { - let dtype = input - .dtype() - .with_nullability(fill_value.dtype().nullability()); - return Ok(Canonical::empty(&dtype).into_array()); - } // Parent kernels decline a fill that is not already constant. Materialize it here so a // lazy scalar, such as a literal `ScalarFnArray`, can still fill nulls. let fill_was_lazy = !fill_value.is::(); @@ -140,9 +132,18 @@ impl ScalarFnVTable for FillNull { fill_value }; - let fill_scalar = fill_value - .as_constant() - .ok_or_else(|| vortex_err!("fill_null fill_value must be a constant/scalar"))?; + let Some(fill_scalar) = fill_value.as_constant() else { + // Slicing a constant fill to an empty range yields an empty canonical array rather + // than a constant. An empty input has no nulls to fill, so there is nothing to do. + vortex_ensure!( + input.is_empty(), + "fill_null fill_value must be a constant/scalar" + ); + let dtype = input + .dtype() + .with_nullability(fill_value.dtype().nullability()); + return Ok(Canonical::empty(&dtype).into_array()); + }; vortex_ensure!( !fill_scalar.is_null(), @@ -412,24 +413,6 @@ mod tests { Ok(()) } - #[test] - fn empty_input_ignores_non_constant_fill() -> VortexResult<()> { - let mut ctx = array_session().create_execution_ctx(); - let input = PrimitiveArray::from_option_iter(Vec::>::new()).into_array(); - let fill = PrimitiveArray::from_iter(Vec::::new()).into_array(); - - let result = FillNull::try_new(input, fill)? - .into_array() - .execute::(&mut ctx)?; - - assert_eq!( - result.dtype(), - &DType::Primitive(PType::I32, Nullability::NonNullable) - ); - assert!(result.is_empty()); - Ok(()) - } - /// Filling a chunked array slices the constant fill per chunk, and an empty slice is a /// canonical array rather than a constant. Stacked fills over an empty chunk must still run. #[test]