Skip to main content

vortex_layout/layouts/zoned/
writer.rs

1//! Write-time assembly for zoned layouts.
2
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-FileCopyrightText: Copyright the Vortex contributors
5
6use std::num::NonZeroUsize;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use futures::StreamExt as _;
11use parking_lot::Mutex;
12use vortex_array::IntoArray;
13use vortex_array::VortexSessionExecute;
14use vortex_array::aggregate_fn::AggregateFnRef;
15use vortex_array::aggregate_fn::AggregateFnVTableExt;
16use vortex_array::aggregate_fn::EmptyOptions;
17use vortex_array::aggregate_fn::NumericalAggregateOpts;
18use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
19use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
20use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin;
21use vortex_array::aggregate_fn::fns::bounded_min::BoundedMinOptions;
22use vortex_array::aggregate_fn::fns::max::Max;
23use vortex_array::aggregate_fn::fns::min::Min;
24use vortex_array::aggregate_fn::fns::nan_count::NanCount;
25use vortex_array::aggregate_fn::fns::null_count::NullCount;
26use vortex_array::aggregate_fn::session::AggregateFnSessionExt;
27use vortex_array::dtype::DType;
28use vortex_error::VortexError;
29use vortex_error::VortexResult;
30use vortex_error::vortex_bail;
31use vortex_io::session::RuntimeSessionExt;
32use vortex_session::VortexSession;
33use vortex_utils::parallelism::get_available_parallelism;
34
35use crate::LayoutRef;
36use crate::LayoutStrategy;
37use crate::LayoutWriterContext;
38use crate::layouts::zoned::AggregateStatsAccumulator;
39use crate::layouts::zoned::ZonedLayout;
40use crate::layouts::zoned::aggregate_partials;
41use crate::layouts::zoned::schema::default_bounded_stat_max_bytes;
42use crate::segments::SegmentSinkRef;
43use crate::sequence::SendableSequentialStream;
44use crate::sequence::SequencePointer;
45use crate::sequence::SequentialArrayStreamExt;
46use crate::sequence::SequentialStreamAdapter;
47use crate::sequence::SequentialStreamExt;
48
49/// Configuration for building zoned layouts.
50///
51/// The input stream is assumed to already be partitioned into one chunk per zone, except
52/// possibly the final partial zone.
53pub struct ZonedLayoutOptions {
54    /// The size of a statistics block
55    pub block_size: NonZeroUsize,
56    /// The aggregate partials to collect for each block.
57    ///
58    /// If unset, the writer chooses pruning aggregates from the input dtype.
59    pub aggregate_fns: Option<Arc<[AggregateFnRef]>>,
60    /// Number of chunks to compute aggregate partials in parallel.
61    pub concurrency: NonZeroUsize,
62}
63
64impl Default for ZonedLayoutOptions {
65    fn default() -> Self {
66        Self {
67            block_size: unsafe { NonZeroUsize::new_unchecked(8192) },
68            aggregate_fns: None,
69            concurrency: unsafe {
70                NonZeroUsize::new_unchecked(get_available_parallelism().unwrap_or(1))
71            },
72        }
73    }
74}
75
76pub struct ZonedStrategy {
77    child: Arc<dyn LayoutStrategy>,
78    stats: Arc<dyn LayoutStrategy>,
79    options: ZonedLayoutOptions,
80}
81
82impl ZonedStrategy {
83    /// Create a writer that emits a data child plus an auxiliary per-zone stats child.
84    pub fn new<Child: LayoutStrategy, Stats: LayoutStrategy>(
85        child: Child,
86        stats: Stats,
87        options: ZonedLayoutOptions,
88    ) -> Self {
89        Self {
90            child: Arc::new(child),
91            stats: Arc::new(stats),
92            options,
93        }
94    }
95}
96
97#[async_trait]
98impl LayoutStrategy for ZonedStrategy {
99    async fn write_stream(
100        &self,
101        ctx: LayoutWriterContext,
102        segment_sink: SegmentSinkRef,
103        stream: SendableSequentialStream,
104        mut eof: SequencePointer,
105        session: &VortexSession,
106    ) -> VortexResult<LayoutRef> {
107        let aggregate_fns = self
108            .options
109            .aggregate_fns
110            .clone()
111            .unwrap_or_else(|| default_zoned_aggregate_fns(stream.dtype(), session));
112        // Fields of a nullable struct are written unmasked, so every aggregate over them sees
113        // values the logical column does not contain: counts come out wrong and min/max come out
114        // wider than the truth. Zone maps hold exact values only, so until the aggregates are
115        // computed over the masked values no zone map is written for such a field.
116        //
117        // TODO(joe): write zone maps for fields under a nullable struct. The struct validity has
118        //  to travel with each chunk, since repartitioning below the struct writer re-chunks the
119        //  stream, and the aggregates then run over `chunk.mask(validity)` while the data child
120        //  still receives the unmasked chunk.
121        if ctx.has_nullable_ancestor() {
122            return self
123                .child
124                .write_stream(ctx, segment_sink, stream, eof, session)
125                .await;
126        }
127        let compute_session = session.clone();
128
129        let stats_accumulator = Arc::new(Mutex::new(AggregateStatsAccumulator::new(
130            stream.dtype(),
131            &aggregate_fns,
132        )));
133        // The accumulator has dropped the aggregates this dtype cannot hold, leaving the ones
134        // this write would record. An aggregate the context forbids fails the write, like a
135        // forbidden array or layout: dropping it silently would leave a file that prunes worse
136        // than the caller asked for, with nothing in the output saying so.
137        let aggregate_fns = stats_accumulator.lock().aggregate_fns();
138        for aggregate_fn in aggregate_fns.iter() {
139            if !ctx.allows_aggregate(&aggregate_fn.id()) {
140                vortex_bail!("Aggregate {} not permitted by ctx", aggregate_fn.id());
141            }
142        }
143
144        let stream_dtype = stream.dtype().clone();
145        let concurrency = self.options.concurrency.get();
146        let stream = stream
147            .map(move |item| {
148                let aggregate_fns = Arc::clone(&aggregate_fns);
149                let session = compute_session.clone();
150                session.handle().spawn_cpu(move || {
151                    let (sequence_id, chunk) = item?;
152                    let partials = aggregate_partials(
153                        &chunk,
154                        &aggregate_fns,
155                        &mut session.create_execution_ctx(),
156                    )?;
157                    Ok::<_, VortexError>((sequence_id, chunk, partials))
158                })
159            })
160            .buffered(concurrency);
161
162        // Accumulate zone stats in stream order so the auxiliary table stays aligned with the
163        // data child.
164        let stats_accumulator2 = Arc::clone(&stats_accumulator);
165        let stream = SequentialStreamAdapter::new(
166            stream_dtype,
167            stream.map(move |item| {
168                let (sequence_id, chunk, partials) = item?;
169                stats_accumulator2.lock().push_partials(partials)?;
170                Ok((sequence_id, chunk))
171            }),
172        )
173        .sendable();
174
175        let block_size = self.options.block_size;
176
177        // The eof used for the data child should appear _before_ our own stats tables.
178        let data_eof = eof.split_off();
179        let data_layout = self
180            .child
181            .write_stream(
182                ctx.clone(),
183                Arc::clone(&segment_sink),
184                stream,
185                data_eof,
186                session,
187            )
188            .await?;
189
190        let mut exec_ctx = session.create_execution_ctx();
191        let Some((stats_array, aggregate_fns)) =
192            stats_accumulator.lock().as_array(&mut exec_ctx)?
193        else {
194            // If we have no stats (e.g. the DType doesn't support them), then we just return the
195            // child layout.
196            return Ok(data_layout);
197        };
198
199        // We must defer creating the stats table LayoutWriter until now, because the DType of
200        // the table depends on which stats were successfully computed.
201        let stats_stream = stats_array
202            .into_array()
203            .to_array_stream()
204            .sequenced(eof.split_off());
205        let zones_layout = self
206            .stats
207            .write_stream(ctx, Arc::clone(&segment_sink), stats_stream, eof, session)
208            .await?;
209
210        Ok(
211            ZonedLayout::try_new(data_layout, zones_layout, block_size, aggregate_fns)?
212                .into_layout(),
213        )
214    }
215}
216
217fn default_zoned_aggregate_fns(dtype: &DType, session: &VortexSession) -> Arc<[AggregateFnRef]> {
218    let (max, min) = match dtype {
219        DType::Utf8(_) | DType::Binary(_) => (
220            BoundedMax.bind(BoundedMaxOptions {
221                max_bytes: default_bounded_stat_max_bytes(),
222            }),
223            BoundedMin.bind(BoundedMinOptions {
224                max_bytes: default_bounded_stat_max_bytes(),
225            }),
226        ),
227        _ => (
228            Max.bind(NumericalAggregateOpts::skip_nans()),
229            Min.bind(NumericalAggregateOpts::skip_nans()),
230        ),
231    };
232
233    // Sum is deliberately absent: zone maps exist to prune, and a zone sum prunes nothing.
234    // Its semantics are also unsettled - null-on-empty was changed in #9113 and reverted in
235    // #9324 - so it is not a stat to record in every zone of every file, let alone freeze
236    // into an edition. File-level statistics still record `Stat::Sum` via `PRUNING_STATS`.
237    let mut aggregate_fns = vec![
238        max,
239        min,
240        NanCount.bind(EmptyOptions),
241        NullCount.bind(EmptyOptions),
242    ];
243
244    // Stats from spatial extension types are discovered from the registry at runtime instead.
245    aggregate_fns.extend(session.aggregate_fns().zone_stat_defaults(dtype));
246
247    aggregate_fns.into()
248}
249
250#[cfg(test)]
251mod tests {
252    use rstest::rstest;
253    use vortex_array::ArrayContext;
254    use vortex_array::IntoArray;
255    use vortex_array::aggregate_fn::AggregateFnVTable;
256    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
257    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin;
258    use vortex_array::aggregate_fn::fns::max::Max;
259    use vortex_array::aggregate_fn::fns::min::Min;
260    use vortex_array::aggregate_fn::fns::sum::Sum;
261    use vortex_array::arrays::ChunkedArray;
262    use vortex_array::dtype::Nullability;
263    use vortex_array::dtype::PType;
264    use vortex_array::extension::datetime::TimeUnit;
265    use vortex_array::extension::datetime::Timestamp;
266    use vortex_buffer::buffer;
267    use vortex_error::VortexExpect;
268    use vortex_io::runtime::Handle;
269    use vortex_io::runtime::single::block_on;
270    use vortex_io::session::RuntimeSession;
271    use vortex_io::session::RuntimeSessionExt;
272    use vortex_utils::aliases::hash_set::HashSet;
273
274    use super::*;
275    use crate::layouts::chunked::writer::ChunkedLayoutStrategy;
276    use crate::layouts::flat::writer::FlatLayoutStrategy;
277    use crate::layouts::zoned::Zoned;
278    use crate::segments::TestSegments;
279    use crate::sequence::SequenceId;
280    use crate::sequence::SequentialArrayStreamExt;
281    use crate::session::LayoutSession;
282
283    /// Write three zones of primitives through `ctx`, returning the aggregates the zoned
284    /// layout recorded.
285    fn write_zones(ctx: LayoutWriterContext) -> VortexResult<Vec<String>> {
286        Ok(write_layout(ctx)?
287            .as_::<Zoned>()
288            .aggregate_fns()
289            .iter()
290            .map(|aggregate_fn| aggregate_fn.id().to_string())
291            .collect())
292    }
293
294    /// Write three zones of primitives through `ctx`, returning the resulting layout.
295    fn write_layout(ctx: LayoutWriterContext) -> VortexResult<LayoutRef> {
296        let strategy = ZonedStrategy::new(
297            ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()),
298            FlatLayoutStrategy::default(),
299            ZonedLayoutOptions {
300                block_size: NonZeroUsize::new(3).vortex_expect("non zero"),
301                ..Default::default()
302            },
303        );
304        let (ptr, eof) = SequenceId::root().split();
305        let stream = ChunkedArray::from_iter([
306            buffer![1, 2, 3].into_array(),
307            buffer![4, 5, 6].into_array(),
308            buffer![7, 8, 9].into_array(),
309        ])
310        .into_array()
311        .to_array_stream()
312        .sequenced(ptr);
313
314        block_on(|handle: Handle| async move {
315            let session = vortex_array::array_session()
316                .with::<LayoutSession>()
317                .with::<RuntimeSession>()
318                .with_handle(handle);
319            strategy
320                .write_stream(
321                    ctx,
322                    Arc::new(TestSegments::default()),
323                    stream,
324                    eof,
325                    &session,
326                )
327                .await
328        })
329    }
330
331    #[test]
332    fn unrestricted_context_writes_the_default_aggregates() -> VortexResult<()> {
333        let written = write_zones(LayoutWriterContext::new(ArrayContext::empty()))?;
334        assert!(written.contains(&Min.id().to_string()));
335        assert!(written.contains(&Max.id().to_string()));
336        assert!(
337            !written.contains(&Sum.id().to_string()),
338            "wrote {written:?}"
339        );
340        Ok(())
341    }
342
343    /// A field of a nullable struct is written unmasked, so no aggregate over it is exact and no
344    /// zone map is written.
345    #[test]
346    fn a_nullable_ancestor_writes_no_zone_map() -> VortexResult<()> {
347        let layout =
348            write_layout(LayoutWriterContext::new(ArrayContext::empty()).with_nullable_ancestor())?;
349        assert!(!layout.is::<Zoned>());
350        Ok(())
351    }
352
353    #[test]
354    fn a_permitted_set_covering_the_defaults_writes_them() -> VortexResult<()> {
355        let ctx = LayoutWriterContext::new(ArrayContext::empty()).with_allowed_aggregates(
356            HashSet::from_iter([Min.id(), Max.id(), NanCount.id(), NullCount.id()]),
357        );
358        assert!(write_zones(ctx)?.contains(&Max.id().to_string()));
359        Ok(())
360    }
361
362    #[test]
363    fn a_forbidden_aggregate_fails_the_write() {
364        let ctx = LayoutWriterContext::new(ArrayContext::empty())
365            .with_allowed_aggregates(HashSet::from_iter([Min.id()]));
366        let error = write_zones(ctx).expect_err("the default aggregates are not all permitted");
367        assert!(
368            error.to_string().contains("not permitted by ctx"),
369            "unexpected error: {error}"
370        );
371    }
372
373    #[test]
374    fn default_aggregates_bound_variable_length_min_max() {
375        let aggregate_fns = default_zoned_aggregate_fns(
376            &DType::Utf8(Nullability::NonNullable),
377            &vortex_array::array_session(),
378        );
379
380        assert_eq!(
381            aggregate_fns[0].as_::<BoundedMax>().max_bytes,
382            default_bounded_stat_max_bytes()
383        );
384        assert_eq!(
385            aggregate_fns[1].as_::<BoundedMin>().max_bytes,
386            default_bounded_stat_max_bytes()
387        );
388    }
389
390    #[test]
391    fn default_aggregates_keep_fixed_width_min_max_exact() {
392        let aggregate_fns =
393            default_zoned_aggregate_fns(&PType::I32.into(), &vortex_array::array_session());
394
395        assert!(aggregate_fns[0].is::<Max>());
396        assert!(aggregate_fns[1].is::<Min>());
397        assert!(aggregate_fns[2].is::<NanCount>());
398    }
399
400    /// Zone maps never carry a sum, whether or not the dtype could hold one.
401    #[rstest]
402    #[case::summable(PType::I32.into())]
403    #[case::not_summable(DType::Extension(
404        Timestamp::new(TimeUnit::Microseconds, Nullability::Nullable).erased(),
405    ))]
406    fn default_aggregates_never_record_sum(#[case] dtype: DType) {
407        let aggregate_fns = default_zoned_aggregate_fns(&dtype, &vortex_array::array_session());
408
409        assert!(
410            aggregate_fns
411                .iter()
412                .all(|aggregate_fn| !aggregate_fn.is::<Sum>())
413        );
414    }
415}