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        let compute_session = session.clone();
113
114        let stats_accumulator = Arc::new(Mutex::new(AggregateStatsAccumulator::new(
115            stream.dtype(),
116            &aggregate_fns,
117        )));
118        // The accumulator has dropped the aggregates this dtype cannot hold, leaving the ones
119        // this write would record. An aggregate the context forbids fails the write, like a
120        // forbidden array or layout: dropping it silently would leave a file that prunes worse
121        // than the caller asked for, with nothing in the output saying so.
122        let aggregate_fns = stats_accumulator.lock().aggregate_fns();
123        for aggregate_fn in aggregate_fns.iter() {
124            if !ctx.allows_aggregate(&aggregate_fn.id()) {
125                vortex_bail!("Aggregate {} not permitted by ctx", aggregate_fn.id());
126            }
127        }
128
129        let stream_dtype = stream.dtype().clone();
130        let concurrency = self.options.concurrency.get();
131        let stream = stream
132            .map(move |item| {
133                let aggregate_fns = Arc::clone(&aggregate_fns);
134                let session = compute_session.clone();
135                session.handle().spawn_cpu(move || {
136                    let (sequence_id, chunk) = item?;
137                    let partials = aggregate_partials(
138                        &chunk,
139                        &aggregate_fns,
140                        &mut session.create_execution_ctx(),
141                    )?;
142                    Ok::<_, VortexError>((sequence_id, chunk, partials))
143                })
144            })
145            .buffered(concurrency);
146
147        // Accumulate zone stats in stream order so the auxiliary table stays aligned with the
148        // data child.
149        let stats_accumulator2 = Arc::clone(&stats_accumulator);
150        let stream = SequentialStreamAdapter::new(
151            stream_dtype,
152            stream.map(move |item| {
153                let (sequence_id, chunk, partials) = item?;
154                stats_accumulator2.lock().push_partials(partials)?;
155                Ok((sequence_id, chunk))
156            }),
157        )
158        .sendable();
159
160        let block_size = self.options.block_size;
161
162        // The eof used for the data child should appear _before_ our own stats tables.
163        let data_eof = eof.split_off();
164        let data_layout = self
165            .child
166            .write_stream(
167                ctx.clone(),
168                Arc::clone(&segment_sink),
169                stream,
170                data_eof,
171                session,
172            )
173            .await?;
174
175        let mut exec_ctx = session.create_execution_ctx();
176        let Some((stats_array, aggregate_fns)) =
177            stats_accumulator.lock().as_array(&mut exec_ctx)?
178        else {
179            // If we have no stats (e.g. the DType doesn't support them), then we just return the
180            // child layout.
181            return Ok(data_layout);
182        };
183
184        // We must defer creating the stats table LayoutWriter until now, because the DType of
185        // the table depends on which stats were successfully computed.
186        let stats_stream = stats_array
187            .into_array()
188            .to_array_stream()
189            .sequenced(eof.split_off());
190        let zones_layout = self
191            .stats
192            .write_stream(ctx, Arc::clone(&segment_sink), stats_stream, eof, session)
193            .await?;
194
195        Ok(
196            ZonedLayout::try_new(data_layout, zones_layout, block_size, aggregate_fns)?
197                .into_layout(),
198        )
199    }
200}
201
202fn default_zoned_aggregate_fns(dtype: &DType, session: &VortexSession) -> Arc<[AggregateFnRef]> {
203    let (max, min) = match dtype {
204        DType::Utf8(_) | DType::Binary(_) => (
205            BoundedMax.bind(BoundedMaxOptions {
206                max_bytes: default_bounded_stat_max_bytes(),
207            }),
208            BoundedMin.bind(BoundedMinOptions {
209                max_bytes: default_bounded_stat_max_bytes(),
210            }),
211        ),
212        _ => (
213            Max.bind(NumericalAggregateOpts::skip_nans()),
214            Min.bind(NumericalAggregateOpts::skip_nans()),
215        ),
216    };
217
218    // Sum is deliberately absent: zone maps exist to prune, and a zone sum prunes nothing.
219    // Its semantics are also unsettled - null-on-empty was changed in #9113 and reverted in
220    // #9324 - so it is not a stat to record in every zone of every file, let alone freeze
221    // into an edition. File-level statistics still record `Stat::Sum` via `PRUNING_STATS`.
222    let mut aggregate_fns = vec![
223        max,
224        min,
225        NanCount.bind(EmptyOptions),
226        NullCount.bind(EmptyOptions),
227    ];
228
229    // Stats from spatial extension types are discovered from the registry at runtime instead.
230    aggregate_fns.extend(session.aggregate_fns().zone_stat_defaults(dtype));
231
232    aggregate_fns.into()
233}
234
235#[cfg(test)]
236mod tests {
237    use rstest::rstest;
238    use vortex_array::ArrayContext;
239    use vortex_array::IntoArray;
240    use vortex_array::aggregate_fn::AggregateFnVTable;
241    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
242    use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin;
243    use vortex_array::aggregate_fn::fns::max::Max;
244    use vortex_array::aggregate_fn::fns::min::Min;
245    use vortex_array::aggregate_fn::fns::sum::Sum;
246    use vortex_array::arrays::ChunkedArray;
247    use vortex_array::dtype::Nullability;
248    use vortex_array::dtype::PType;
249    use vortex_array::extension::datetime::TimeUnit;
250    use vortex_array::extension::datetime::Timestamp;
251    use vortex_buffer::buffer;
252    use vortex_error::VortexExpect;
253    use vortex_io::runtime::Handle;
254    use vortex_io::runtime::single::block_on;
255    use vortex_io::session::RuntimeSession;
256    use vortex_io::session::RuntimeSessionExt;
257    use vortex_utils::aliases::hash_set::HashSet;
258
259    use super::*;
260    use crate::layouts::chunked::writer::ChunkedLayoutStrategy;
261    use crate::layouts::flat::writer::FlatLayoutStrategy;
262    use crate::layouts::zoned::Zoned;
263    use crate::segments::TestSegments;
264    use crate::sequence::SequenceId;
265    use crate::sequence::SequentialArrayStreamExt;
266    use crate::session::LayoutSession;
267
268    /// Write three zones of primitives through `ctx`, returning the aggregates the zoned
269    /// layout recorded.
270    fn write_zones(ctx: LayoutWriterContext) -> VortexResult<Vec<String>> {
271        let strategy = ZonedStrategy::new(
272            ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()),
273            FlatLayoutStrategy::default(),
274            ZonedLayoutOptions {
275                block_size: NonZeroUsize::new(3).vortex_expect("non zero"),
276                ..Default::default()
277            },
278        );
279        let (ptr, eof) = SequenceId::root().split();
280        let stream = ChunkedArray::from_iter([
281            buffer![1, 2, 3].into_array(),
282            buffer![4, 5, 6].into_array(),
283            buffer![7, 8, 9].into_array(),
284        ])
285        .into_array()
286        .to_array_stream()
287        .sequenced(ptr);
288
289        let layout = block_on(|handle: Handle| async move {
290            let session = vortex_array::array_session()
291                .with::<LayoutSession>()
292                .with::<RuntimeSession>()
293                .with_handle(handle);
294            strategy
295                .write_stream(
296                    ctx,
297                    Arc::new(TestSegments::default()),
298                    stream,
299                    eof,
300                    &session,
301                )
302                .await
303        })?;
304
305        Ok(layout
306            .as_::<Zoned>()
307            .aggregate_fns()
308            .iter()
309            .map(|aggregate_fn| aggregate_fn.id().to_string())
310            .collect())
311    }
312
313    #[test]
314    fn unrestricted_context_writes_the_default_aggregates() -> VortexResult<()> {
315        let written = write_zones(LayoutWriterContext::new(ArrayContext::empty()))?;
316        assert!(written.contains(&Min.id().to_string()));
317        assert!(written.contains(&Max.id().to_string()));
318        assert!(
319            !written.contains(&Sum.id().to_string()),
320            "wrote {written:?}"
321        );
322        Ok(())
323    }
324
325    #[test]
326    fn a_permitted_set_covering_the_defaults_writes_them() -> VortexResult<()> {
327        let ctx = LayoutWriterContext::new(ArrayContext::empty()).with_allowed_aggregates(
328            HashSet::from_iter([Min.id(), Max.id(), NanCount.id(), NullCount.id()]),
329        );
330        assert!(write_zones(ctx)?.contains(&Max.id().to_string()));
331        Ok(())
332    }
333
334    #[test]
335    fn a_forbidden_aggregate_fails_the_write() {
336        let ctx = LayoutWriterContext::new(ArrayContext::empty())
337            .with_allowed_aggregates(HashSet::from_iter([Min.id()]));
338        let error = write_zones(ctx).expect_err("the default aggregates are not all permitted");
339        assert!(
340            error.to_string().contains("not permitted by ctx"),
341            "unexpected error: {error}"
342        );
343    }
344
345    #[test]
346    fn default_aggregates_bound_variable_length_min_max() {
347        let aggregate_fns = default_zoned_aggregate_fns(
348            &DType::Utf8(Nullability::NonNullable),
349            &vortex_array::array_session(),
350        );
351
352        assert_eq!(
353            aggregate_fns[0].as_::<BoundedMax>().max_bytes,
354            default_bounded_stat_max_bytes()
355        );
356        assert_eq!(
357            aggregate_fns[1].as_::<BoundedMin>().max_bytes,
358            default_bounded_stat_max_bytes()
359        );
360    }
361
362    #[test]
363    fn default_aggregates_keep_fixed_width_min_max_exact() {
364        let aggregate_fns =
365            default_zoned_aggregate_fns(&PType::I32.into(), &vortex_array::array_session());
366
367        assert!(aggregate_fns[0].is::<Max>());
368        assert!(aggregate_fns[1].is::<Min>());
369        assert!(aggregate_fns[2].is::<NanCount>());
370    }
371
372    /// Zone maps never carry a sum, whether or not the dtype could hold one.
373    #[rstest]
374    #[case::summable(PType::I32.into())]
375    #[case::not_summable(DType::Extension(
376        Timestamp::new(TimeUnit::Microseconds, Nullability::Nullable).erased(),
377    ))]
378    fn default_aggregates_never_record_sum(#[case] dtype: DType) {
379        let aggregate_fns = default_zoned_aggregate_fns(&dtype, &vortex_array::array_session());
380
381        assert!(
382            aggregate_fns
383                .iter()
384                .all(|aggregate_fn| !aggregate_fn.is::<Sum>())
385        );
386    }
387}