Skip to main content

vortex_layout/layouts/
file_stats.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::future;
5use std::marker::PhantomData;
6use std::sync::Arc;
7
8use futures::StreamExt;
9use itertools::Itertools;
10use parking_lot::Mutex;
11use vortex_array::ArrayRef;
12use vortex_array::ExecutionCtx;
13use vortex_array::VortexSessionExecute;
14use vortex_array::aggregate_fn::fns::sum::sum;
15use vortex_array::arrays::StructArray;
16use vortex_array::arrays::struct_::StructArrayExt;
17use vortex_array::builders::ArrayBuilder;
18use vortex_array::builders::BoolBuilder;
19use vortex_array::builders::builder_with_capacity_in;
20use vortex_array::dtype::DType;
21use vortex_array::dtype::FieldName;
22use vortex_array::dtype::Nullability;
23use vortex_array::dtype::PType;
24use vortex_array::expr::stats::Precision;
25use vortex_array::expr::stats::Stat;
26use vortex_array::scalar::Scalar;
27use vortex_array::scalar::ScalarTruncation;
28use vortex_array::scalar::lower_bound;
29use vortex_array::scalar::upper_bound;
30use vortex_array::stats::StatsSet;
31use vortex_array::validity::Validity;
32use vortex_buffer::BufferString;
33use vortex_buffer::ByteBuffer;
34use vortex_error::VortexExpect;
35use vortex_error::VortexResult;
36use vortex_error::vortex_panic;
37use vortex_session::VortexSession;
38
39use crate::layouts::zoned::MAX_IS_TRUNCATED;
40use crate::layouts::zoned::MIN_IS_TRUNCATED;
41use crate::sequence::SendableSequentialStream;
42use crate::sequence::SequenceId;
43use crate::sequence::SequentialStreamAdapter;
44use crate::sequence::SequentialStreamExt;
45
46pub fn accumulate_stats(
47    stream: SendableSequentialStream,
48    stats: Arc<[Stat]>,
49    max_variable_length_statistics_size: usize,
50    session: &VortexSession,
51) -> (FileStatsAccumulator, SendableSequentialStream) {
52    let accumulator = FileStatsAccumulator::new(
53        stream.dtype(),
54        stats,
55        max_variable_length_statistics_size,
56        session,
57    );
58    let stream = SequentialStreamAdapter::new(
59        stream.dtype().clone(),
60        stream.scan(accumulator.clone(), |acc, item| {
61            future::ready(Some(acc.process(item)))
62        }),
63    )
64    .sendable();
65    (accumulator, stream)
66}
67
68/// Accumulates write-time statistics for a single file column.
69struct StatsAccumulator {
70    builders: Vec<Box<dyn StatsArrayBuilder>>,
71    length: usize,
72}
73
74impl StatsAccumulator {
75    fn new(dtype: &DType, stats: &[Stat], max_variable_length_statistics_size: usize) -> Self {
76        if !supports_file_stats(dtype) {
77            return Self {
78                builders: Vec::new(),
79                length: 0,
80            };
81        }
82
83        let builders = stats
84            .iter()
85            .filter_map(|&stat| {
86                stat.dtype(dtype).map(|stat_dtype| {
87                    stats_builder_with_capacity(
88                        stat,
89                        &stat_dtype.as_nullable(),
90                        1024,
91                        max_variable_length_statistics_size,
92                    )
93                })
94            })
95            .collect::<Vec<_>>();
96
97        Self {
98            builders,
99            length: 0,
100        }
101    }
102
103    fn push_chunk(&mut self, array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()> {
104        for builder in &mut self.builders {
105            if let Some(value) = array.statistics().compute_stat(builder.stat(), ctx)? {
106                builder.append_scalar(value.cast(&value.dtype().as_nullable())?)?;
107            } else {
108                builder.append_null();
109            }
110        }
111        self.length += 1;
112        Ok(())
113    }
114
115    fn as_array(&mut self, ctx: &mut ExecutionCtx) -> VortexResult<Option<StructArray>> {
116        let mut names = Vec::new();
117        let mut fields = Vec::new();
118
119        for builder in self
120            .builders
121            .iter_mut()
122            // We sort the stats so the DType is deterministic based on which stats are present.
123            .sorted_unstable_by_key(|builder| builder.stat())
124        {
125            let values = builder.finish();
126
127            // We drop any all-null stats columns.
128            if values.all_invalid(ctx)? {
129                continue;
130            }
131
132            names.extend(values.names);
133            fields.extend(values.arrays);
134        }
135
136        if names.is_empty() {
137            return Ok(None);
138        }
139
140        StructArray::try_new(names.into(), fields, self.length, Validity::NonNullable).map(Some)
141    }
142
143    /// Returns an aggregated stats set for the table.
144    fn as_stats_set(&mut self, stats: &[Stat], ctx: &mut ExecutionCtx) -> VortexResult<StatsSet> {
145        let mut stats_set = StatsSet::default();
146        let Some(stats_table) = self.as_array(ctx)? else {
147            return Ok(stats_set);
148        };
149
150        for &stat in stats {
151            let Some(values) = stats_table.unmasked_field_by_name_opt(stat.name()) else {
152                continue;
153            };
154
155            match stat {
156                Stat::Max if is_varlen_dtype(values.dtype()) && !values.all_valid(ctx)? => {
157                    // A null truncated varlen max can mean either an empty chunk or no finite
158                    // upper bound, so aggregating by skipping nulls would be unsound.
159                    continue;
160                }
161                Stat::Sum if !values.all_valid(ctx)? => {
162                    // `values` holds each chunk's `Stat::Sum` from `push_chunk`. The legacy `Sum`
163                    // aggregate behind it returns zero for empty input and null only on overflow.
164                    // Summing `values` below would skip those nulls and report a wrong exact
165                    // total, so leave the file sum unset.
166                    continue;
167                }
168                Stat::Min | Stat::Max | Stat::Sum => {
169                    if let Some(s) = values.statistics().compute_stat(stat, ctx)?
170                        && let Some(v) = s.into_value()
171                    {
172                        let precision = if stat_was_truncated(&stats_table, stat, ctx)? {
173                            Precision::inexact(v)
174                        } else {
175                            Precision::exact(v)
176                        };
177                        stats_set.set(stat, precision)
178                    }
179                }
180                Stat::NullCount | Stat::NaNCount | Stat::UncompressedSizeInBytes => {
181                    if let Some(sum_value) = sum(values, ctx)?
182                        .cast(&DType::Primitive(PType::U64, Nullability::Nullable))?
183                        .into_value()
184                    {
185                        stats_set.set(stat, Precision::exact(sum_value));
186                    }
187                }
188                Stat::IsConstant | Stat::IsSorted | Stat::IsStrictSorted => {}
189            }
190        }
191        Ok(stats_set)
192    }
193}
194
195fn stat_was_truncated(
196    stats_table: &StructArray,
197    stat: Stat,
198    ctx: &mut ExecutionCtx,
199) -> VortexResult<bool> {
200    let field_name = match stat {
201        Stat::Min => MIN_IS_TRUNCATED,
202        Stat::Max => MAX_IS_TRUNCATED,
203        _ => return Ok(false),
204    };
205    let Some(is_truncated) = stats_table.unmasked_field_by_name_opt(field_name) else {
206        return Ok(false);
207    };
208
209    Ok(is_truncated
210        .statistics()
211        .compute_stat(Stat::Max, ctx)?
212        .is_some_and(|max| max.as_bool().value() == Some(true)))
213}
214
215fn supports_file_stats(dtype: &DType) -> bool {
216    !matches!(dtype, DType::Variant(_))
217}
218
219fn is_varlen_dtype(dtype: &DType) -> bool {
220    matches!(dtype, DType::Utf8(_) | DType::Binary(_))
221}
222
223fn stats_builder_with_capacity(
224    stat: Stat,
225    dtype: &DType,
226    capacity: usize,
227    max_length: usize,
228) -> Box<dyn StatsArrayBuilder> {
229    let values_builder = builder_with_capacity_in(
230        dtype,
231        capacity,
232        vortex_buffer::BufferAllocatorRef::static_ref(),
233    );
234    match stat {
235        Stat::Max => match dtype {
236            DType::Utf8(_) => Box::new(TruncatedMaxBinaryStatsBuilder::<BufferString>::new(
237                values_builder,
238                BoolBuilder::with_capacity_in(
239                    Nullability::NonNullable,
240                    capacity,
241                    vortex_buffer::BufferAllocatorRef::static_ref(),
242                ),
243                max_length,
244            )),
245            DType::Binary(_) => Box::new(TruncatedMaxBinaryStatsBuilder::<ByteBuffer>::new(
246                values_builder,
247                BoolBuilder::with_capacity_in(
248                    Nullability::NonNullable,
249                    capacity,
250                    vortex_buffer::BufferAllocatorRef::static_ref(),
251                ),
252                max_length,
253            )),
254            _ => Box::new(StatNameArrayBuilder::new(stat, values_builder)),
255        },
256        Stat::Min => match dtype {
257            DType::Utf8(_) => Box::new(TruncatedMinBinaryStatsBuilder::<BufferString>::new(
258                values_builder,
259                BoolBuilder::with_capacity_in(
260                    Nullability::NonNullable,
261                    capacity,
262                    vortex_buffer::BufferAllocatorRef::static_ref(),
263                ),
264                max_length,
265            )),
266            DType::Binary(_) => Box::new(TruncatedMinBinaryStatsBuilder::<ByteBuffer>::new(
267                values_builder,
268                BoolBuilder::with_capacity_in(
269                    Nullability::NonNullable,
270                    capacity,
271                    vortex_buffer::BufferAllocatorRef::static_ref(),
272                ),
273                max_length,
274            )),
275            _ => Box::new(StatNameArrayBuilder::new(stat, values_builder)),
276        },
277        _ => Box::new(StatNameArrayBuilder::new(stat, values_builder)),
278    }
279}
280
281/// Arrays with their associated names, reduced version of a `StructArray`.
282struct NamedArrays {
283    names: Vec<FieldName>,
284    arrays: Vec<ArrayRef>,
285}
286
287impl NamedArrays {
288    fn all_invalid(&self, ctx: &mut ExecutionCtx) -> VortexResult<bool> {
289        self.arrays[0].all_invalid(ctx)
290    }
291}
292
293trait StatsArrayBuilder: Send {
294    fn stat(&self) -> Stat;
295
296    fn append_scalar(&mut self, value: Scalar) -> VortexResult<()>;
297
298    fn append_null(&mut self);
299
300    fn finish(&mut self) -> NamedArrays;
301}
302
303struct StatNameArrayBuilder {
304    stat: Stat,
305    builder: Box<dyn ArrayBuilder>,
306}
307
308impl StatNameArrayBuilder {
309    fn new(stat: Stat, builder: Box<dyn ArrayBuilder>) -> Self {
310        Self { stat, builder }
311    }
312}
313
314impl StatsArrayBuilder for StatNameArrayBuilder {
315    fn stat(&self) -> Stat {
316        self.stat
317    }
318
319    fn append_scalar(&mut self, value: Scalar) -> VortexResult<()> {
320        self.builder.append_scalar(&value)
321    }
322
323    fn append_null(&mut self) {
324        self.builder.append_null()
325    }
326
327    fn finish(&mut self) -> NamedArrays {
328        NamedArrays {
329            names: vec![self.stat.name().into()],
330            arrays: vec![self.builder.finish()],
331        }
332    }
333}
334
335struct TruncatedMaxBinaryStatsBuilder<T: ScalarTruncation> {
336    values: Box<dyn ArrayBuilder>,
337    is_truncated: BoolBuilder,
338    max_value_length: usize,
339    _marker: PhantomData<T>,
340}
341
342impl<T: ScalarTruncation> TruncatedMaxBinaryStatsBuilder<T> {
343    fn new(
344        values: Box<dyn ArrayBuilder>,
345        is_truncated: BoolBuilder,
346        max_value_length: usize,
347    ) -> Self {
348        Self {
349            values,
350            is_truncated,
351            max_value_length,
352            _marker: PhantomData,
353        }
354    }
355}
356
357struct TruncatedMinBinaryStatsBuilder<T: ScalarTruncation> {
358    values: Box<dyn ArrayBuilder>,
359    is_truncated: BoolBuilder,
360    max_value_length: usize,
361    _marker: PhantomData<T>,
362}
363
364impl<T: ScalarTruncation> TruncatedMinBinaryStatsBuilder<T> {
365    fn new(
366        values: Box<dyn ArrayBuilder>,
367        is_truncated: BoolBuilder,
368        max_value_length: usize,
369    ) -> Self {
370        Self {
371            values,
372            is_truncated,
373            max_value_length,
374            _marker: PhantomData,
375        }
376    }
377}
378
379impl<T: ScalarTruncation> StatsArrayBuilder for TruncatedMaxBinaryStatsBuilder<T> {
380    fn stat(&self) -> Stat {
381        Stat::Max
382    }
383
384    fn append_scalar(&mut self, value: Scalar) -> VortexResult<()> {
385        let nullability = value.dtype().nullability();
386        if let Some((upper_bound, truncated)) =
387            upper_bound(T::from_scalar(value)?, self.max_value_length, nullability)
388        {
389            self.values.append_scalar(&upper_bound)?;
390            self.is_truncated.append_value(truncated);
391        } else {
392            self.append_null()
393        }
394        Ok(())
395    }
396
397    fn append_null(&mut self) {
398        ArrayBuilder::append_null(self.values.as_mut());
399        self.is_truncated.append_value(false);
400    }
401
402    fn finish(&mut self) -> NamedArrays {
403        NamedArrays {
404            names: vec![Stat::Max.name().into(), MAX_IS_TRUNCATED.into()],
405            arrays: vec![
406                ArrayBuilder::finish(self.values.as_mut()),
407                ArrayBuilder::finish(&mut self.is_truncated),
408            ],
409        }
410    }
411}
412
413impl<T: ScalarTruncation> StatsArrayBuilder for TruncatedMinBinaryStatsBuilder<T> {
414    fn stat(&self) -> Stat {
415        Stat::Min
416    }
417
418    fn append_scalar(&mut self, value: Scalar) -> VortexResult<()> {
419        let nullability = value.dtype().nullability();
420        if let Some((lower_bound, truncated)) =
421            lower_bound(T::from_scalar(value)?, self.max_value_length, nullability)
422        {
423            self.values.append_scalar(&lower_bound)?;
424            self.is_truncated.append_value(truncated);
425        } else {
426            self.append_null()
427        }
428        Ok(())
429    }
430
431    fn append_null(&mut self) {
432        ArrayBuilder::append_null(self.values.as_mut());
433        self.is_truncated.append_value(false);
434    }
435
436    fn finish(&mut self) -> NamedArrays {
437        NamedArrays {
438            names: vec![Stat::Min.name().into(), MIN_IS_TRUNCATED.into()],
439            arrays: vec![
440                ArrayBuilder::finish(self.values.as_mut()),
441                ArrayBuilder::finish(&mut self.is_truncated),
442            ],
443        }
444    }
445}
446
447/// An array stream processor that computes aggregate statistics for all fields.
448///
449/// Note: for now this only collects top-level struct fields.
450#[derive(Clone)]
451pub struct FileStatsAccumulator {
452    stats: Arc<[Stat]>,
453    accumulators: Arc<Mutex<Vec<StatsAccumulator>>>,
454    ctx: Arc<Mutex<ExecutionCtx>>,
455}
456
457impl FileStatsAccumulator {
458    fn new(
459        dtype: &DType,
460        stats: Arc<[Stat]>,
461        max_variable_length_statistics_size: usize,
462        session: &VortexSession,
463    ) -> Self {
464        let accumulators = Arc::new(Mutex::new(match dtype.as_struct_fields_opt() {
465            Some(struct_dtype) => {
466                if dtype.nullability() == Nullability::Nullable {
467                    // top level dtype could be nullable, but we don't support it yet
468                    vortex_panic!(
469                        "FileStatsAccumulator temporarily does not support nullable top-level structs, got: {}. Use Validity::NonNullable",
470                        dtype
471                    );
472                }
473
474                struct_dtype
475                    .fields()
476                    .map(|field_dtype| {
477                        StatsAccumulator::new(
478                            &field_dtype,
479                            &stats,
480                            max_variable_length_statistics_size,
481                        )
482                    })
483                    .collect()
484            }
485            None => [StatsAccumulator::new(
486                dtype,
487                &stats,
488                max_variable_length_statistics_size,
489            )]
490            .into(),
491        }));
492
493        Self {
494            stats,
495            accumulators,
496            ctx: Arc::new(Mutex::new(session.create_execution_ctx())),
497        }
498    }
499
500    fn process(
501        &self,
502        chunk: VortexResult<(SequenceId, ArrayRef)>,
503    ) -> VortexResult<(SequenceId, ArrayRef)> {
504        let (sequence_id, chunk) = chunk?;
505        let mut ctx = self.ctx.lock();
506        if chunk.dtype().is_struct() {
507            let struct_chunk = chunk.clone().execute::<StructArray>(&mut ctx)?;
508            for (acc, field) in self
509                .accumulators
510                .lock()
511                .iter_mut()
512                .zip_eq(struct_chunk.iter_unmasked_fields())
513            {
514                acc.push_chunk(field, &mut ctx)?;
515            }
516        } else {
517            self.accumulators.lock()[0].push_chunk(&chunk, &mut ctx)?;
518        }
519        Ok((sequence_id, chunk))
520    }
521
522    pub fn stats_sets(&self) -> Vec<StatsSet> {
523        let mut ctx = self.ctx.lock();
524        self.accumulators
525            .lock()
526            .iter_mut()
527            .map(|acc| {
528                acc.as_stats_set(&self.stats, &mut ctx)
529                    .vortex_expect("as_stats_table should not fail")
530            })
531            .collect()
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use rstest::rstest;
538    use vortex_array::IntoArray;
539    use vortex_array::array_session;
540    use vortex_array::arrays::BoolArray;
541    use vortex_array::arrays::PrimitiveArray;
542    use vortex_array::arrays::bool::BoolArrayExt;
543    use vortex_array::builders::VarBinViewBuilder;
544    use vortex_buffer::BitBuffer;
545    use vortex_buffer::buffer;
546
547    use super::*;
548
549    #[rstest]
550    #[case(DType::Utf8(Nullability::NonNullable))]
551    #[case(DType::Binary(Nullability::NonNullable))]
552    fn truncates_accumulated_stats(#[case] dtype: DType) {
553        let mut ctx = array_session().create_execution_ctx();
554        let mut builder = VarBinViewBuilder::with_capacity_in(
555            dtype.clone(),
556            2,
557            vortex_buffer::BufferAllocatorRef::statically_allocated(),
558        );
559        builder.append_value("Value to be truncated");
560        builder.append_value("untruncated");
561        let mut builder2 = VarBinViewBuilder::with_capacity_in(
562            dtype,
563            2,
564            vortex_buffer::BufferAllocatorRef::statically_allocated(),
565        );
566        builder2.append_value("Another");
567        builder2.append_value("wait a minute");
568        let mut acc =
569            StatsAccumulator::new(builder.dtype(), &[Stat::Max, Stat::Min, Stat::Sum], 12);
570        acc.push_chunk(&builder.finish(), &mut ctx)
571            .vortex_expect("push_chunk should succeed for test data");
572        acc.push_chunk(&builder2.finish(), &mut ctx)
573            .vortex_expect("push_chunk should succeed for test data");
574        let stats_table = acc
575            .as_array(&mut ctx)
576            .unwrap()
577            .expect("Must have stats table");
578        assert_eq!(
579            stats_table.names().as_ref(),
580            &[
581                Stat::Max.name(),
582                MAX_IS_TRUNCATED,
583                Stat::Min.name(),
584                MIN_IS_TRUNCATED,
585            ]
586        );
587        let field1_bool = stats_table
588            .unmasked_field(1)
589            .clone()
590            .execute::<BoolArray>(&mut ctx)
591            .unwrap();
592        assert_eq!(
593            field1_bool.to_bit_buffer(),
594            BitBuffer::from(vec![false, true])
595        );
596        let field3_bool = stats_table
597            .unmasked_field(3)
598            .clone()
599            .execute::<BoolArray>(&mut ctx)
600            .unwrap();
601        assert_eq!(
602            field3_bool.to_bit_buffer(),
603            BitBuffer::from(vec![true, false])
604        );
605    }
606
607    #[rstest]
608    #[case(DType::Utf8(Nullability::NonNullable))]
609    #[case(DType::Binary(Nullability::NonNullable))]
610    fn truncated_accumulated_stats_are_inexact(#[case] dtype: DType) {
611        let mut ctx = array_session().create_execution_ctx();
612        let mut builder = VarBinViewBuilder::with_capacity_in(
613            dtype,
614            2,
615            vortex_buffer::BufferAllocatorRef::statically_allocated(),
616        );
617        builder.append_value("Value to be truncated");
618        builder.append_value("Another truncated value");
619        let mut acc = StatsAccumulator::new(builder.dtype(), &[Stat::Max, Stat::Min], 12);
620        acc.push_chunk(&builder.finish(), &mut ctx)
621            .vortex_expect("push_chunk should succeed for test data");
622
623        let stats = acc
624            .as_stats_set(&[Stat::Max, Stat::Min], &mut ctx)
625            .vortex_expect("as_stats_set should succeed for test data");
626
627        assert!(matches!(stats.get(Stat::Min), Precision::Inexact(_)));
628        assert!(matches!(stats.get(Stat::Max), Precision::Inexact(_)));
629    }
630
631    #[test]
632    fn fixed_width_stats_omit_is_truncated_columns() {
633        let mut ctx = array_session().create_execution_ctx();
634        let array = buffer![0, 1, 2].into_array();
635        let mut acc = StatsAccumulator::new(array.dtype(), &[Stat::Max, Stat::Min, Stat::Sum], 12);
636        acc.push_chunk(&array, &mut ctx)
637            .vortex_expect("push_chunk should succeed for test array");
638        let stats_table = acc
639            .as_array(&mut ctx)
640            .unwrap()
641            .expect("Must have stats table");
642        assert_eq!(
643            stats_table.names().as_ref(),
644            &[Stat::Max.name(), Stat::Min.name(), Stat::Sum.name()]
645        );
646    }
647
648    #[rstest]
649    #[case::one_chunk_overflows(vec![vec![Some(i64::MAX), Some(1)], vec![Some(2)]], None)]
650    #[case::all_chunks_overflow(vec![vec![Some(i64::MAX), Some(1)]; 2], None)]
651    #[case::total_overflows(vec![vec![Some(i64::MAX)], vec![Some(1)]], None)]
652    #[case::nullable_values(vec![vec![None, Some(3)], vec![Some(4), None]], Some(7))]
653    #[case::all_null_chunk(vec![vec![None], vec![Some(5)]], Some(5))]
654    fn combines_chunk_sums(
655        #[case] chunks: Vec<Vec<Option<i64>>>,
656        #[case] expected: Option<i64>,
657    ) -> VortexResult<()> {
658        let mut ctx = array_session().create_execution_ctx();
659        let dtype = DType::Primitive(PType::I64, Nullability::Nullable);
660        let mut acc = StatsAccumulator::new(&dtype, &[Stat::Sum], 12);
661        for chunk in chunks {
662            acc.push_chunk(
663                &PrimitiveArray::from_option_iter(chunk).into_array(),
664                &mut ctx,
665            )?;
666        }
667
668        let stats = acc.as_stats_set(&[Stat::Sum], &mut ctx)?;
669        assert_eq!(
670            stats.get(Stat::Sum),
671            expected.map_or(Precision::Absent, Precision::exact)
672        );
673
674        Ok(())
675    }
676}