Skip to main content

vortex_array/aggregate_fn/
storage_pushdown.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Opt-in pushdown of an aggregate into the storage of one extension type.
5//!
6//! See [`ExtensionStoragePushdown`] for when such a pushdown is correct.
7
8use std::mem;
9
10use vortex_error::VortexResult;
11use vortex_error::vortex_bail;
12use vortex_error::vortex_err;
13use vortex_session::VortexSession;
14
15use crate::ArrayRef;
16use crate::Canonical;
17use crate::Columnar;
18use crate::ExecutionCtx;
19use crate::aggregate_fn::Accumulator;
20use crate::aggregate_fn::AggregateArgs;
21use crate::aggregate_fn::AggregateDTypes;
22use crate::aggregate_fn::AggregateFnId;
23use crate::aggregate_fn::AggregateFnVTable;
24use crate::aggregate_fn::DynAccumulator;
25use crate::arrays::ConstantArray;
26use crate::arrays::Extension;
27use crate::arrays::extension::ExtensionArrayExt;
28use crate::dtype::DType;
29use crate::dtype::extension::ExtVTable;
30use crate::scalar::Scalar;
31
32/// Runs aggregate `A` on the storage values of extension type `E`.
33///
34/// The input must have extension type `E`, and `A` must support its storage dtype. Binding fails
35/// for any other input. The wrapper has a separate aggregate ID, `<A>[<E>]`, formed from the IDs
36/// of `A` and `E`. Registering it does not replace `A`.
37///
38/// Aggregates that already support extension dtypes directly do not need this wrapper.
39///
40/// # Correctness
41///
42/// A registration **must** satisfy both conditions:
43///
44/// - The aggregation is defined for logical values of `E`.
45/// - For every valid input, aggregating the storage values produces the storage representation
46///   of the logical aggregate result. This requires storage to preserve whichever properties `A`
47///   relies on, such as ordering, equality, arithmetic, or NaN classification.
48///
49/// For example, minimum is defined for timestamps, and their integer storage preserves their
50/// ordering, so both conditions hold. Summing timestamps fails the first condition even though
51/// their storage supports addition.
52///
53/// These conditions are not checked. An invalid registration can silently return incorrect results.
54///
55/// # Results and partials
56///
57/// Results and partial states are those of `A` over the storage dtype. Results are not converted
58/// back to `E`. For example, the minimum and maximum of a timestamp column are raw `i64` values
59/// in the column's time unit.
60///
61/// Partial states can only be combined when the aggregate, options, and full input dtype match.
62/// This includes extension metadata, so timestamp partials with different time units must not be
63/// combined even if their storage dtypes match.
64///
65/// # Example
66///
67/// Timestamps compare like their `i64` storage, so `is_sorted` can be pushed down. Register the
68/// pair once on the session, then bind it like any other aggregate:
69///
70/// ```
71/// # use vortex_array::IntoArray;
72/// # use vortex_array::VortexSessionExecute;
73/// # use vortex_array::aggregate_fn::Accumulator;
74/// # use vortex_array::aggregate_fn::DynAccumulator;
75/// # use vortex_array::aggregate_fn::ExtensionStoragePushdown;
76/// # use vortex_array::aggregate_fn::fns::is_sorted::IsSorted;
77/// # use vortex_array::aggregate_fn::fns::is_sorted::IsSortedOptions;
78/// # use vortex_array::aggregate_fn::session::AggregateFnSessionExt;
79/// # use vortex_array::arrays::ExtensionArray;
80/// # use vortex_array::arrays::PrimitiveArray;
81/// # use vortex_array::dtype::DType;
82/// # use vortex_array::dtype::Nullability;
83/// # use vortex_array::extension::datetime::TimeUnit;
84/// # use vortex_array::extension::datetime::Timestamp;
85/// # fn main() -> vortex_error::VortexResult<()> {
86/// let session = vortex_array::array_session();
87///
88/// // Register `vortex.is_sorted[vortex.timestamp]`.
89/// let is_sorted = ExtensionStoragePushdown::new(IsSorted, Timestamp);
90/// session.aggregate_fns().register(is_sorted.clone());
91///
92/// // Aggregate a timestamp column through its storage.
93/// let ext_dtype = Timestamp::new(TimeUnit::Milliseconds, Nullability::NonNullable).erased();
94/// let column = ExtensionArray::new(
95///     ext_dtype.clone(),
96///     PrimitiveArray::from_iter([1_000i64, 2_000, 3_000]).into_array(),
97/// )
98/// .into_array();
99///
100/// let mut acc = Accumulator::try_new(
101///     is_sorted,
102///     IsSortedOptions { strict: true },
103///     DType::Extension(ext_dtype),
104/// )?;
105/// acc.accumulate(&column, &mut session.create_execution_ctx())?;
106/// assert!(bool::try_from(&acc.finish()?)?);
107/// # Ok(())
108/// # }
109/// ```
110#[derive(Clone, Debug)]
111pub struct ExtensionStoragePushdown<A: AggregateFnVTable, E: ExtVTable> {
112    aggregate: A,
113    ext: E,
114    id: AggregateFnId,
115}
116
117impl<A: AggregateFnVTable, E: ExtVTable> ExtensionStoragePushdown<A, E> {
118    /// Push `aggregate` down into the storage of extension type `ext`.
119    ///
120    /// Only do this when `aggregate` over the storage is correct for `ext`; see
121    /// [`ExtensionStoragePushdown`] for how to check.
122    pub fn new(aggregate: A, ext: E) -> Self {
123        #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")]
124        let id = AggregateFnId::new(&format!("{}[{}]", aggregate.id(), ext.id()));
125        Self { aggregate, ext, id }
126    }
127
128    /// The storage dtype of `dtype`, if it is an extension dtype of type `E`.
129    fn storage_dtype<'a>(&self, dtype: &'a DType) -> Option<&'a DType> {
130        dtype
131            .as_extension_opt()
132            .filter(|ext_dtype| ext_dtype.is::<E>())
133            .map(|ext_dtype| ext_dtype.storage_dtype())
134    }
135
136    /// The arguments of the storage aggregate `A`.
137    ///
138    /// `A` shares this aggregate's options, return and partial dtypes, and its input dtype is
139    /// borrowed from the extension dtype, so this allocates nothing.
140    fn storage_args<'a>(
141        &self,
142        args: AggregateArgs<'a, A::Options>,
143    ) -> VortexResult<AggregateArgs<'a, A::Options>> {
144        let dtype = self.storage_dtype(args.dtype).ok_or_else(|| {
145            vortex_err!(
146                "Aggregate function {} expects extension type {}, got {}",
147                self.id,
148                self.ext.id(),
149                args.dtype
150            )
151        })?;
152        Ok(AggregateArgs { dtype, ..args })
153    }
154
155    /// Accumulate a storage batch into `state` through an [`Accumulator`] of `A`, so that the
156    /// kernels and cached statistics of `A` on the storage encoding still apply.
157    fn accumulate_storage(
158        &self,
159        args: AggregateArgs<'_, A::Options>,
160        state: &mut A::Partial,
161        storage: &ArrayRef,
162        ctx: &mut ExecutionCtx,
163    ) -> VortexResult<()> {
164        let mut accumulator = Accumulator::from_dtypes(
165            self.aggregate.clone(),
166            args.options.clone(),
167            AggregateDTypes::new(
168                args.dtype.clone(),
169                args.return_dtype.clone(),
170                args.partial_dtype.clone(),
171            ),
172        );
173        accumulator.accumulate(storage, ctx)?;
174        let Some(batch) = accumulator.take_partial() else {
175            return Ok(());
176        };
177        let running = mem::replace(state, self.aggregate.empty_partial(args)?);
178        *state = self.aggregate.merge_partials(args, running, batch)?;
179        Ok(())
180    }
181}
182
183impl<A: AggregateFnVTable, E: ExtVTable> AggregateFnVTable for ExtensionStoragePushdown<A, E> {
184    type Options = A::Options;
185    type Partial = A::Partial;
186
187    fn id(&self) -> AggregateFnId {
188        self.id
189    }
190
191    fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
192        self.aggregate.serialize(options)
193    }
194
195    fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult<Self::Options> {
196        self.aggregate.deserialize(metadata, session)
197    }
198
199    fn return_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
200        self.aggregate
201            .return_dtype(options, self.storage_dtype(input_dtype)?)
202    }
203
204    fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option<DType> {
205        self.aggregate
206            .partial_dtype(options, self.storage_dtype(input_dtype)?)
207    }
208
209    fn empty_partial(&self, args: AggregateArgs<'_, Self::Options>) -> VortexResult<Self::Partial> {
210        self.aggregate.empty_partial(self.storage_args(args)?)
211    }
212
213    fn partial_from_scalar(
214        &self,
215        args: AggregateArgs<'_, Self::Options>,
216        scalar: &Scalar,
217    ) -> VortexResult<Self::Partial> {
218        self.aggregate
219            .partial_from_scalar(self.storage_args(args)?, scalar)
220    }
221
222    fn merge_partials(
223        &self,
224        args: AggregateArgs<'_, Self::Options>,
225        first: Self::Partial,
226        second: Self::Partial,
227    ) -> VortexResult<Self::Partial> {
228        self.aggregate
229            .merge_partials(self.storage_args(args)?, first, second)
230    }
231
232    fn to_scalar(
233        &self,
234        args: AggregateArgs<'_, Self::Options>,
235        partial: &Self::Partial,
236    ) -> VortexResult<Scalar> {
237        self.aggregate.to_scalar(self.storage_args(args)?, partial)
238    }
239
240    fn is_saturated(
241        &self,
242        args: AggregateArgs<'_, Self::Options>,
243        partial: &Self::Partial,
244    ) -> bool {
245        self.storage_args(args)
246            .is_ok_and(|args| self.aggregate.is_saturated(args, partial))
247    }
248
249    /// Unwrap extension arrays to their storage before decoding, so the storage accumulator sees
250    /// the storage encoding.
251    fn try_accumulate(
252        &self,
253        args: AggregateArgs<'_, Self::Options>,
254        state: &mut Self::Partial,
255        batch: &ArrayRef,
256        ctx: &mut ExecutionCtx,
257    ) -> VortexResult<bool> {
258        let Some(array) = batch.as_opt::<Extension>() else {
259            return Ok(false);
260        };
261        self.accumulate_storage(self.storage_args(args)?, state, array.storage_array(), ctx)?;
262        Ok(true)
263    }
264
265    fn accumulate(
266        &self,
267        args: AggregateArgs<'_, Self::Options>,
268        state: &mut Self::Partial,
269        batch: &Columnar,
270        ctx: &mut ExecutionCtx,
271    ) -> VortexResult<()> {
272        let args = self.storage_args(args)?;
273        match batch {
274            Columnar::Canonical(Canonical::Extension(array)) => {
275                self.accumulate_storage(args, state, array.storage_array(), ctx)
276            }
277            // A constant storage array has no encoding to dispatch on and no statistics worth
278            // caching, so it goes straight to `A` rather than through a nested accumulator.
279            Columnar::Constant(constant) => {
280                let storage = constant.scalar().as_extension().to_storage_scalar();
281                self.aggregate.accumulate(
282                    args,
283                    state,
284                    &Columnar::Constant(ConstantArray::new(storage, constant.len())),
285                    ctx,
286                )
287            }
288            Columnar::Canonical(_) => {
289                vortex_bail!(
290                    "Aggregate function {} expects an extension array, got {}",
291                    self.id,
292                    batch.dtype()
293                )
294            }
295        }
296    }
297
298    fn finalize(
299        &self,
300        args: AggregateArgs<'_, Self::Options>,
301        states: ArrayRef,
302    ) -> VortexResult<ArrayRef> {
303        self.aggregate.finalize(self.storage_args(args)?, states)
304    }
305
306    fn finalize_scalar(
307        &self,
308        args: AggregateArgs<'_, Self::Options>,
309        partial: &Self::Partial,
310    ) -> VortexResult<Scalar> {
311        self.aggregate
312            .finalize_scalar(self.storage_args(args)?, partial)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use rstest::rstest;
319    use vortex_error::VortexResult;
320
321    use super::ExtensionStoragePushdown;
322    use crate::IntoArray;
323    use crate::VortexSessionExecute;
324    use crate::aggregate_fn::Accumulator;
325    use crate::aggregate_fn::AggregateFnVTable;
326    use crate::aggregate_fn::DynAccumulator;
327    use crate::aggregate_fn::NumericalAggregateOpts;
328    use crate::aggregate_fn::fns::is_sorted::IsSorted;
329    use crate::aggregate_fn::fns::is_sorted::IsSortedOptions;
330    use crate::aggregate_fn::fns::min_max::MinMax;
331    use crate::aggregate_fn::session::AggregateFnSessionExt;
332    use crate::arrays::ConstantArray;
333    use crate::arrays::ExtensionArray;
334    use crate::arrays::PrimitiveArray;
335    use crate::dtype::DType;
336    use crate::dtype::Nullability;
337    use crate::extension::datetime::Date;
338    use crate::extension::datetime::Time;
339    use crate::extension::datetime::TimeUnit;
340    use crate::extension::datetime::Timestamp;
341    use crate::scalar::Scalar;
342
343    #[test]
344    fn pushes_down_into_storage() -> VortexResult<()> {
345        let session = crate::array_session();
346        let aggregate = ExtensionStoragePushdown::new(MinMax, Timestamp);
347        session.aggregate_fns().register(aggregate.clone());
348        assert!(
349            session
350                .aggregate_fns()
351                .find_plugin(&aggregate.id())
352                .is_some()
353        );
354
355        let ext_dtype = Timestamp::new(TimeUnit::Milliseconds, Nullability::Nullable).erased();
356        let storage =
357            PrimitiveArray::from_option_iter([Some(3_000i64), None, Some(1_000)]).into_array();
358        let array = ExtensionArray::new(ext_dtype.clone(), storage.clone()).into_array();
359
360        let mut ctx = session.create_execution_ctx();
361        let mut acc = Accumulator::try_new(
362            aggregate,
363            NumericalAggregateOpts::default(),
364            DType::Extension(ext_dtype),
365        )?;
366        acc.accumulate(&array, &mut ctx)?;
367
368        let mut expected = Accumulator::try_new(
369            MinMax,
370            NumericalAggregateOpts::default(),
371            storage.dtype().clone(),
372        )?;
373        expected.accumulate(&storage, &mut ctx)?;
374
375        assert_eq!(acc.finish()?, expected.finish()?);
376        Ok(())
377    }
378
379    #[test]
380    fn combines_flushed_partials() -> VortexResult<()> {
381        let session = crate::array_session();
382        let mut ctx = session.create_execution_ctx();
383        let ext_dtype = Timestamp::new(TimeUnit::Milliseconds, Nullability::Nullable).erased();
384        let batch = |values: [Option<i64>; 2]| {
385            ExtensionArray::new(
386                ext_dtype.clone(),
387                PrimitiveArray::from_option_iter(values).into_array(),
388            )
389            .into_array()
390        };
391        let accumulator = || {
392            Accumulator::try_new(
393                ExtensionStoragePushdown::new(MinMax, Timestamp),
394                NumericalAggregateOpts::default(),
395                DType::Extension(ext_dtype.clone()),
396            )
397        };
398
399        let mut first = accumulator()?;
400        first.accumulate(&batch([Some(3_000), None]), &mut ctx)?;
401        let mut second = accumulator()?;
402        second.accumulate(&batch([Some(1_000), Some(500)]), &mut ctx)?;
403        first.combine_partials(&second.flush()?)?;
404
405        let mut expected = accumulator()?;
406        expected.accumulate(&batch([Some(3_000), None]), &mut ctx)?;
407        expected.accumulate(&batch([Some(1_000), Some(500)]), &mut ctx)?;
408
409        assert_eq!(first.finish()?, expected.finish()?);
410        Ok(())
411    }
412
413    #[test]
414    fn accumulates_constant_batches() -> VortexResult<()> {
415        let session = crate::array_session();
416        let mut ctx = session.create_execution_ctx();
417        let ext_dtype = Timestamp::new(TimeUnit::Milliseconds, Nullability::Nullable).erased();
418        let constant = |value: i64| {
419            ConstantArray::new(
420                Scalar::extension_ref(
421                    ext_dtype.clone(),
422                    Scalar::primitive(value, Nullability::Nullable),
423                ),
424                3,
425            )
426            .into_array()
427        };
428
429        let mut acc = Accumulator::try_new(
430            ExtensionStoragePushdown::new(MinMax, Timestamp),
431            NumericalAggregateOpts::default(),
432            DType::Extension(ext_dtype.clone()),
433        )?;
434        acc.accumulate(&constant(2_000), &mut ctx)?;
435        acc.accumulate(&constant(1_000), &mut ctx)?;
436
437        let mut expected = Accumulator::try_new(
438            MinMax,
439            NumericalAggregateOpts::default(),
440            ext_dtype.storage_dtype().clone(),
441        )?;
442        expected.accumulate(
443            &PrimitiveArray::from_option_iter([Some(2_000i64), Some(1_000)]).into_array(),
444            &mut ctx,
445        )?;
446
447        assert_eq!(acc.finish()?, expected.finish()?);
448        Ok(())
449    }
450
451    #[test]
452    fn rejects_other_extension_types() {
453        let date = DType::Extension(Date::new(TimeUnit::Days, Nullability::Nullable).erased());
454        assert!(
455            ExtensionStoragePushdown::new(MinMax, Timestamp)
456                .return_dtype(&NumericalAggregateOpts::default(), &date)
457                .is_none()
458        );
459    }
460
461    #[test]
462    fn default_session_registers_temporal_order_aggregates() {
463        let session = crate::array_session();
464        let aggregate_fns = session.aggregate_fns();
465        for id in [
466            ExtensionStoragePushdown::new(IsSorted, Date).id(),
467            ExtensionStoragePushdown::new(IsSorted, Time).id(),
468            ExtensionStoragePushdown::new(IsSorted, Timestamp).id(),
469            ExtensionStoragePushdown::new(MinMax, Date).id(),
470            ExtensionStoragePushdown::new(MinMax, Time).id(),
471            ExtensionStoragePushdown::new(MinMax, Timestamp).id(),
472        ] {
473            assert!(
474                aggregate_fns.find_plugin(&id).is_some(),
475                "{id} is not registered"
476            );
477        }
478    }
479
480    #[rstest]
481    // Nulls sort first.
482    #[case::sorted([None, Some(1_000), Some(2_000), Some(2_000)], false, true)]
483    #[case::unsorted([Some(2_000), Some(1_000), None, Some(3_000)], false, false)]
484    #[case::strict_with_duplicates([None, Some(1_000), Some(2_000), Some(2_000)], true, false)]
485    fn is_sorted_over_timestamps(
486        #[case] values: [Option<i64>; 4],
487        #[case] strict: bool,
488        #[case] expected: bool,
489    ) -> VortexResult<()> {
490        let session = crate::array_session();
491        let mut ctx = session.create_execution_ctx();
492        let ext_dtype = Timestamp::new(TimeUnit::Milliseconds, Nullability::Nullable).erased();
493        let array = ExtensionArray::new(
494            ext_dtype.clone(),
495            PrimitiveArray::from_option_iter(values).into_array(),
496        )
497        .into_array();
498
499        let mut acc = Accumulator::try_new(
500            ExtensionStoragePushdown::new(IsSorted, Timestamp),
501            IsSortedOptions { strict },
502            DType::Extension(ext_dtype),
503        )?;
504        acc.accumulate(&array, &mut ctx)?;
505
506        assert_eq!(bool::try_from(&acc.finish()?)?, expected);
507        Ok(())
508    }
509
510    #[test]
511    fn min_max_over_dates_matches_storage() -> VortexResult<()> {
512        let session = crate::array_session();
513        let mut ctx = session.create_execution_ctx();
514        let ext_dtype = Date::new(TimeUnit::Days, Nullability::Nullable).erased();
515        let storage =
516            PrimitiveArray::from_option_iter([Some(20i32), None, Some(-3), Some(7)]).into_array();
517        let array = ExtensionArray::new(ext_dtype.clone(), storage.clone()).into_array();
518
519        let mut acc = Accumulator::try_new(
520            ExtensionStoragePushdown::new(MinMax, Date),
521            NumericalAggregateOpts::default(),
522            DType::Extension(ext_dtype),
523        )?;
524        acc.accumulate(&array, &mut ctx)?;
525
526        let mut expected = Accumulator::try_new(
527            MinMax,
528            NumericalAggregateOpts::default(),
529            storage.dtype().clone(),
530        )?;
531        expected.accumulate(&storage, &mut ctx)?;
532
533        assert_eq!(acc.finish()?, expected.finish()?);
534        Ok(())
535    }
536}