Skip to main content

vortex_layout/layouts/list/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use futures::StreamExt;
8use futures::future::try_join;
9use futures::future::try_join_all;
10use vortex_array::ArrayRef;
11use vortex_array::ExecutionCtx;
12use vortex_array::IntoArray;
13use vortex_array::VortexSessionExecute;
14use vortex_array::arrays::ConstantArray;
15use vortex_array::arrays::List;
16use vortex_array::arrays::ListView;
17use vortex_array::arrays::PrimitiveArray;
18use vortex_array::arrays::list::ListDataParts;
19use vortex_array::arrays::listview::list_from_list_view;
20use vortex_array::builtins::ArrayBuiltins;
21use vortex_array::dtype::DType;
22use vortex_array::dtype::Nullability;
23use vortex_array::dtype::PType;
24use vortex_array::matcher::Matcher;
25use vortex_array::scalar_fn::fns::operators::Operator;
26use vortex_error::VortexExpect;
27use vortex_error::VortexResult;
28use vortex_error::vortex_bail;
29use vortex_io::kanal_ext::KanalExt;
30use vortex_io::session::RuntimeSessionExt;
31use vortex_session::VortexSession;
32
33use crate::LayoutRef;
34use crate::LayoutStrategy;
35use crate::LayoutWriterContext;
36use crate::layouts::flat::writer::FlatLayoutStrategy;
37use crate::layouts::list::ListLayout;
38use crate::segments::SegmentSinkRef;
39use crate::sequence::SendableSequentialStream;
40use crate::sequence::SequenceId;
41use crate::sequence::SequencePointer;
42use crate::sequence::SequentialStream;
43use crate::sequence::SequentialStreamAdapter;
44use crate::sequence::SequentialStreamExt;
45
46/// Item carried on each child sub-stream: a sequenced, materialized chunk.
47type ChildChunk = VortexResult<(SequenceId, ArrayRef)>;
48
49/// Strategy for writing list-typed arrays, with a fallback for non-list dtypes.
50///
51/// This is a *structural* writer that decomposes a list column into independent `elements`,
52/// `offsets`, and (when nullable) `validity` sub-columns, each written through its own downstream
53/// strategy, producing a single [`ListLayout`].
54///
55/// For list-typed input the strategy transposes the whole column stream into three sub-streams:
56///  1. Each chunk is canonicalized to a [`ListArray`] (rebuilding a [`ListView`] via
57///     [`list_from_list_view`] when necessary).
58///  2. `offsets` are rebased to global `u64` positions (cumulative across chunks) so the single
59///     `offsets` child indexes into the concatenated `elements` child.
60///  3. `elements`, `offsets`, and `validity` are streamed to their child strategies concurrently.
61///
62/// For input whose dtype is not [`DType::List`], the stream is forwarded unchanged to the
63/// configured `fallback` strategy.
64///
65/// [`ListArray`]: vortex_array::arrays::ListArray
66#[derive(Clone)]
67pub struct ListLayoutStrategy {
68    elements: Arc<dyn LayoutStrategy>,
69    offsets: Arc<dyn LayoutStrategy>,
70    validity: Arc<dyn LayoutStrategy>,
71    fallback: Arc<dyn LayoutStrategy>,
72}
73
74impl Default for ListLayoutStrategy {
75    /// Routes every child (elements, offsets, validity) and the non-list fallback through
76    /// [`FlatLayoutStrategy`]. Override individual children with the `with_*` builder methods.
77    fn default() -> Self {
78        let flat: Arc<dyn LayoutStrategy> = Arc::new(FlatLayoutStrategy::default());
79        Self {
80            elements: Arc::clone(&flat),
81            offsets: Arc::clone(&flat),
82            validity: Arc::clone(&flat),
83            fallback: flat,
84        }
85    }
86}
87
88impl ListLayoutStrategy {
89    /// Strategy for the `elements` child.
90    pub fn with_elements(mut self, elements: Arc<dyn LayoutStrategy>) -> Self {
91        self.elements = elements;
92        self
93    }
94
95    /// Strategy for the `offsets` child.
96    pub fn with_offsets(mut self, offsets: Arc<dyn LayoutStrategy>) -> Self {
97        self.offsets = offsets;
98        self
99    }
100
101    /// Strategy for the `validity` child (written only when the list is nullable).
102    pub fn with_validity(mut self, validity: Arc<dyn LayoutStrategy>) -> Self {
103        self.validity = validity;
104        self
105    }
106
107    /// Strategy for non-list input, which is forwarded through this strategy unchanged.
108    pub fn with_fallback(mut self, fallback: Arc<dyn LayoutStrategy>) -> Self {
109        self.fallback = fallback;
110        self
111    }
112}
113
114#[async_trait]
115impl LayoutStrategy for ListLayoutStrategy {
116    async fn write_stream(
117        &self,
118        ctx: LayoutWriterContext,
119        segment_sink: SegmentSinkRef,
120        stream: SendableSequentialStream,
121        mut eof: SequencePointer,
122        session: &VortexSession,
123    ) -> VortexResult<LayoutRef> {
124        let dtype = stream.dtype().clone();
125        if !dtype.is_list() {
126            return self
127                .fallback
128                .write_stream(ctx, segment_sink, stream, eof, session)
129                .await;
130        }
131
132        let is_nullable = dtype.is_nullable();
133        let element_dtype = dtype
134            .as_list_element_opt()
135            .vortex_expect("DType is List")
136            .as_ref()
137            .clone();
138        // Global (whole-column) offsets are cumulative and may exceed the input offset width,
139        // so definsively widen.
140        let offsets_dtype = DType::Primitive(PType::U64, Nullability::NonNullable);
141
142        // One bounded sub-stream per child: elements, offsets, and (when nullable) validity.
143        let (elements_tx, elements_rx) = kanal::bounded_async::<ChildChunk>(1);
144        let (offsets_tx, offsets_rx) = kanal::bounded_async::<ChildChunk>(1);
145        let (validity_tx, validity_rx) = if is_nullable {
146            let (tx, rx) = kanal::bounded_async::<ChildChunk>(1);
147            (Some(tx), Some(rx))
148        } else {
149            (None, None)
150        };
151
152        // Transpose the list column into its child sub-streams and rebase offsets to global
153        // positions. Kept joined with the child writers below so producer errors surface rather
154        // than being hidden as an early channel close.
155        let fanout_fut = transpose_list_column(
156            stream,
157            session.clone(),
158            elements_tx,
159            offsets_tx,
160            validity_tx,
161        );
162
163        // Spawn a writer per child sub-stream, concurrently.
164        let handle = session.handle();
165        let mut child_specs: Vec<(
166            DType,
167            Arc<dyn LayoutStrategy>,
168            kanal::AsyncReceiver<ChildChunk>,
169        )> = vec![
170            (element_dtype, Arc::clone(&self.elements), elements_rx),
171            (offsets_dtype, Arc::clone(&self.offsets), offsets_rx),
172        ];
173        if let Some(validity_rx) = validity_rx {
174            child_specs.push((
175                DType::Bool(Nullability::NonNullable),
176                Arc::clone(&self.validity),
177                validity_rx,
178            ));
179        }
180
181        let layout_futures: Vec<_> = child_specs
182            .into_iter()
183            .map(|(child_dtype, strategy, rx)| {
184                let child_stream =
185                    SequentialStreamAdapter::new(child_dtype, rx.into_stream().boxed()).sendable();
186                let child_eof = eof.split_off();
187                let ctx = ctx.clone();
188                let segment_sink = Arc::clone(&segment_sink);
189                let session = session.clone();
190                handle.spawn_nested(move |_| async move {
191                    strategy
192                        .write_stream(ctx, segment_sink, child_stream, child_eof, &session)
193                        .await
194                })
195            })
196            .collect();
197
198        let (_, layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?;
199        let mut layouts = layouts.into_iter();
200        let elements_layout = layouts.next().vortex_expect("elements layout present");
201        let offsets_layout = layouts.next().vortex_expect("offsets layout present");
202        let validity_layout =
203            is_nullable.then(|| layouts.next().vortex_expect("validity layout present"));
204
205        Ok(ListLayout::new(dtype, elements_layout, offsets_layout, validity_layout).into_layout())
206    }
207}
208
209/// Transpose a list column into its `elements`, `offsets`, and (when present) `validity` child
210/// sub-streams, rebasing each chunk's local `offsets` to global `u64` positions so the single
211/// `offsets` child indexes into the concatenated `elements` child.
212///
213/// `validity_tx` is `Some` exactly when the list is nullable. Errors surface to the caller, which
214/// joins this against the child writers, rather than being hidden as an early channel close.
215async fn transpose_list_column(
216    mut stream: SendableSequentialStream,
217    session: VortexSession,
218    elements_tx: kanal::AsyncSender<ChildChunk>,
219    offsets_tx: kanal::AsyncSender<ChildChunk>,
220    validity_tx: Option<kanal::AsyncSender<ChildChunk>>,
221) -> VortexResult<()> {
222    let mut exec_ctx = session.create_execution_ctx();
223    let mut element_base: u64 = 0;
224    let mut first = true;
225    let mut saw_chunk = false;
226    while let Some(chunk) = stream.next().await {
227        let (sequence_id, array) = chunk?;
228        saw_chunk = true;
229        let mut sp = sequence_id.descend();
230        let ListDataParts {
231            elements,
232            offsets,
233            validity,
234            ..
235        } = canonicalize_to_list_parts(array, &mut exec_ctx)?;
236        let n_elements = elements.len() as u64;
237        let row_count = offsets.len().saturating_sub(1);
238        let offsets = global_offsets(offsets, element_base, first, &mut exec_ctx)?;
239        element_base += n_elements;
240        first = false;
241
242        if elements_tx
243            .send(Ok((sp.advance(), elements)))
244            .await
245            .is_err()
246            || offsets_tx.send(Ok((sp.advance(), offsets))).await.is_err()
247        {
248            vortex_bail!("list child writer finished before all chunks were sent");
249        }
250        if let Some(validity_tx) = &validity_tx {
251            let validity = validity
252                .execute_mask(row_count, &mut exec_ctx)?
253                .into_array();
254            if validity_tx
255                .send(Ok((sp.advance(), validity)))
256                .await
257                .is_err()
258            {
259                vortex_bail!("list validity writer finished before all chunks were sent");
260            }
261        }
262    }
263    if !saw_chunk {
264        vortex_bail!("ListLayoutStrategy needs at least one chunk");
265    }
266    Ok(())
267}
268
269/// Canonicalize a list-dtype array into [`ListDataParts`].
270fn canonicalize_to_list_parts(
271    array: ArrayRef,
272    exec_ctx: &mut ExecutionCtx,
273) -> VortexResult<ListDataParts> {
274    let canonical = array.execute_until::<AnyList>(exec_ctx)?;
275    if let Some(list) = canonical.as_opt::<List>() {
276        Ok(list.into_owned().into_data_parts())
277    } else if let Some(view) = canonical.as_opt::<ListView>() {
278        Ok(list_from_list_view(view.into_owned(), exec_ctx)?.into_data_parts())
279    } else {
280        unreachable!("AnyList matcher guarantees List or ListView")
281    }
282}
283
284/// Rebase a chunk's local `offsets` into global `u64` positions for the whole-column `offsets`
285/// child. Each chunk's offsets are shifted by `element_base` (the number of elements already
286/// emitted) so they index into the concatenated `elements`. The duplicated boundary offset is
287/// dropped on every chunk after the first, so the concatenation of all chunks' contributions is a
288/// single monotonic `[0, .., total_elements]` array of length `row_count + 1`.
289fn global_offsets(
290    offsets: ArrayRef,
291    element_base: u64,
292    first: bool,
293    exec_ctx: &mut ExecutionCtx,
294) -> VortexResult<ArrayRef> {
295    let widened = offsets.cast(DType::Primitive(PType::U64, Nullability::NonNullable))?;
296    let based = if element_base == 0 {
297        widened
298    } else {
299        let base = ConstantArray::new(element_base, widened.len()).into_array();
300        widened.binary(base, Operator::Add)?
301    };
302    let based = if first {
303        based
304    } else {
305        based.slice(1..based.len())?
306    };
307    // Materialize so the child sub-stream carries a concrete array rather than a lazy expression.
308    Ok(based.execute::<PrimitiveArray>(exec_ctx)?.into_array())
309}
310
311/// Matcher for `Array<List>` or `Array<ListView>`.
312struct AnyList;
313
314impl Matcher for AnyList {
315    type Match<'a> = ();
316
317    fn try_match(array: &ArrayRef) -> Option<Self::Match<'_>> {
318        (array.as_opt::<List>().is_some() || array.as_opt::<ListView>().is_some()).then_some(())
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use futures::stream;
325    use vortex_array::ArrayContext;
326    use vortex_array::VortexSessionExecute;
327    use vortex_array::array_session;
328    use vortex_array::arrays::BoolArray;
329    use vortex_array::arrays::ChunkedArray;
330    use vortex_array::arrays::ListArray;
331    use vortex_array::arrays::StructArray;
332    use vortex_array::dtype::Nullability;
333    use vortex_array::dtype::PType;
334    use vortex_array::validity::Validity;
335    use vortex_buffer::buffer;
336    use vortex_io::session::RuntimeSession;
337
338    use super::*;
339    use crate::layouts::chunked::writer::ChunkedLayoutStrategy;
340    use crate::layouts::flat::writer::FlatLayoutStrategy;
341    use crate::layouts::table::TableStrategy;
342    use crate::segments::TestSegments;
343    use crate::sequence::SequentialArrayStreamExt;
344    use crate::session::LayoutSession;
345
346    fn layout_test_session() -> VortexSession {
347        array_session()
348            .with::<LayoutSession>()
349            .with::<RuntimeSession>()
350            .with_tokio()
351    }
352
353    fn flat_list_strategy() -> ListLayoutStrategy {
354        ListLayoutStrategy::default()
355    }
356
357    async fn write<S: LayoutStrategy>(strategy: &S, array: ArrayRef) -> VortexResult<LayoutRef> {
358        let session = layout_test_session();
359        let segments = Arc::new(TestSegments::default());
360        let (ptr, eof) = SequenceId::root().split();
361        let stream = array.to_array_stream().sequenced(ptr);
362        strategy
363            .write_stream(
364                ArrayContext::empty().into(),
365                segments,
366                stream,
367                eof,
368                &session,
369            )
370            .await
371    }
372
373    fn i32_list_dtype(nullable: bool) -> DType {
374        DType::List(
375            Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)),
376            if nullable {
377                Nullability::Nullable
378            } else {
379                Nullability::NonNullable
380            },
381        )
382    }
383
384    fn create_basic_list(validity: Validity) -> ArrayRef {
385        let mut ctx = array_session().create_execution_ctx();
386        ListArray::try_new(
387            buffer![1i32, 2, 3, 4, 5].into_array(),
388            buffer![0u32, 2, 5, 5].into_array(),
389            validity,
390            &mut ctx,
391        )
392        .unwrap()
393        .into_array()
394    }
395
396    #[tokio::test]
397    async fn basic_non_nullable_input() -> VortexResult<()> {
398        let list = create_basic_list(Validity::NonNullable);
399
400        let layout = write(&flat_list_strategy(), list).await?;
401        assert_eq!(layout.row_count(), 3);
402
403        insta::assert_snapshot!(layout.display_tree(), @"
404        vortex.list, dtype: list(i32), children: 2
405        ├── elements: vortex.flat, dtype: i32, segment: 0
406        └── offsets: vortex.flat, dtype: u64, segment: 1
407        ");
408        Ok(())
409    }
410
411    #[tokio::test]
412    async fn basic_nullable_input() -> VortexResult<()> {
413        let list = create_basic_list(Validity::Array(
414            BoolArray::from_iter([true, false, true]).into_array(),
415        ));
416
417        let layout = write(&flat_list_strategy(), list).await?;
418        assert_eq!(layout.row_count(), 3);
419
420        insta::assert_snapshot!(layout.display_tree(), @"
421        vortex.list, dtype: list(i32)?, children: 3
422        ├── elements: vortex.flat, dtype: i32, segment: 0
423        ├── offsets: vortex.flat, dtype: u64, segment: 1
424        └── validity: vortex.flat, dtype: bool, segment: 2
425        ");
426        Ok(())
427    }
428
429    /// Non-list input dispatches to the fallback strategy unchanged.
430    #[tokio::test]
431    async fn non_list_input_routes_to_fallback() -> VortexResult<()> {
432        let primitive = buffer![1i32, 2, 3].into_array();
433        let layout = write(&flat_list_strategy(), primitive).await?;
434        insta::assert_snapshot!(layout.display_tree(), @"vortex.flat, dtype: i32, segment: 0");
435        Ok(())
436    }
437
438    #[tokio::test]
439    async fn empty_stream_errors() {
440        let segments = Arc::new(TestSegments::default());
441        let (_, eof) = SequenceId::root().split();
442        let empty = stream::empty::<VortexResult<(SequenceId, ArrayRef)>>().boxed();
443        let stream = SequentialStreamAdapter::new(i32_list_dtype(false), empty).sendable();
444        let session = layout_test_session();
445
446        let res = flat_list_strategy()
447            .write_stream(
448                ArrayContext::empty().into(),
449                segments,
450                stream,
451                eof,
452                &session,
453            )
454            .await;
455        assert!(res.is_err())
456    }
457
458    #[tokio::test]
459    async fn list_of_struct_tree() -> VortexResult<()> {
460        let mut ctx = array_session().create_execution_ctx();
461        let struct_array = StructArray::from_fields(
462            [
463                ("a", buffer![1i32, 2, 3, 4, 5].into_array()),
464                ("b", buffer![10i32, 20, 30, 40, 50].into_array()),
465            ]
466            .as_slice(),
467        )?
468        .into_array();
469        let list = ListArray::try_new(
470            struct_array,
471            buffer![0u32, 2, 5, 5].into_array(),
472            Validity::NonNullable,
473            &mut ctx,
474        )?
475        .into_array();
476
477        let flat: Arc<dyn LayoutStrategy> = Arc::new(FlatLayoutStrategy::default());
478        let table_strategy: Arc<dyn LayoutStrategy> =
479            Arc::new(TableStrategy::new(Arc::clone(&flat), Arc::clone(&flat)));
480        let writer = ListLayoutStrategy::default().with_elements(table_strategy);
481
482        let layout = write(&writer, list).await?;
483        insta::assert_snapshot!(layout.display_tree(), @"
484        vortex.list, dtype: list({a=i32, b=i32}), children: 2
485        ├── elements: vortex.struct, dtype: {a=i32, b=i32}, children: 2
486        │   ├── a: vortex.flat, dtype: i32, segment: 1
487        │   └── b: vortex.flat, dtype: i32, segment: 2
488        └── offsets: vortex.flat, dtype: u64, segment: 0
489        ");
490        Ok(())
491    }
492
493    #[tokio::test]
494    async fn list_of_list_tree() -> VortexResult<()> {
495        let mut ctx = array_session().create_execution_ctx();
496        let inner_list = ListArray::try_new(
497            buffer![1i32, 2, 3, 4, 5, 6].into_array(),
498            buffer![0u32, 2, 5, 5, 6].into_array(),
499            Validity::NonNullable,
500            &mut ctx,
501        )?
502        .into_array();
503        let list = ListArray::try_new(
504            inner_list,
505            buffer![0u32, 2, 4].into_array(),
506            Validity::NonNullable,
507            &mut ctx,
508        )?
509        .into_array();
510
511        let writer =
512            ListLayoutStrategy::default().with_elements(Arc::new(ListLayoutStrategy::default()));
513        let layout = write(&writer, list).await?;
514        insta::assert_snapshot!(layout.display_tree(), @"
515        vortex.list, dtype: list(list(i32)), children: 2
516        ├── elements: vortex.list, dtype: list(i32), children: 2
517        │   ├── elements: vortex.flat, dtype: i32, segment: 1
518        │   └── offsets: vortex.flat, dtype: u64, segment: 2
519        └── offsets: vortex.flat, dtype: u64, segment: 0
520        ");
521        Ok(())
522    }
523
524    #[tokio::test]
525    async fn list_of_list_of_list_tree() -> VortexResult<()> {
526        let mut ctx = array_session().create_execution_ctx();
527        let innermost = ListArray::try_new(
528            buffer![1i32, 2, 3, 4].into_array(),
529            buffer![0u32, 2, 4].into_array(),
530            Validity::NonNullable,
531            &mut ctx,
532        )?
533        .into_array();
534        let middle = ListArray::try_new(
535            innermost,
536            buffer![0u32, 2].into_array(),
537            Validity::NonNullable,
538            &mut ctx,
539        )?
540        .into_array();
541        let outer = ListArray::try_new(
542            middle,
543            buffer![0u32, 1].into_array(),
544            Validity::NonNullable,
545            &mut ctx,
546        )?
547        .into_array();
548
549        let writer = ListLayoutStrategy::default().with_elements(Arc::new(
550            ListLayoutStrategy::default().with_elements(Arc::new(ListLayoutStrategy::default())),
551        ));
552        let layout = write(&writer, outer).await?;
553        insta::assert_snapshot!(layout.display_tree(), @"
554        vortex.list, dtype: list(list(list(i32))), children: 2
555        ├── elements: vortex.list, dtype: list(list(i32)), children: 2
556        │   ├── elements: vortex.list, dtype: list(i32), children: 2
557        │   │   ├── elements: vortex.flat, dtype: i32, segment: 2
558        │   │   └── offsets: vortex.flat, dtype: u64, segment: 3
559        │   └── offsets: vortex.flat, dtype: u64, segment: 1
560        └── offsets: vortex.flat, dtype: u64, segment: 0
561        ");
562        Ok(())
563    }
564
565    #[tokio::test]
566    async fn chunked_list_input_with_chunked_strategy_succeeds() -> VortexResult<()> {
567        let mut ctx = array_session().create_execution_ctx();
568        let chunk0 = ListArray::try_new(
569            buffer![1i32, 2, 3].into_array(),
570            buffer![0u32, 2, 3].into_array(),
571            Validity::NonNullable,
572            &mut ctx,
573        )
574        .unwrap()
575        .into_array();
576        let chunk1 = ListArray::try_new(
577            buffer![4i32, 5, 6, 7].into_array(),
578            buffer![0u32, 1, 4].into_array(),
579            Validity::NonNullable,
580            &mut ctx,
581        )
582        .unwrap()
583        .into_array();
584
585        let chunked =
586            ChunkedArray::try_new(vec![chunk0, chunk1], i32_list_dtype(false))?.into_array();
587
588        let layout = write(&ChunkedLayoutStrategy::new(flat_list_strategy()), chunked).await?;
589
590        insta::assert_snapshot!(layout.display_tree(), @"
591        vortex.chunked, dtype: list(i32), children: 2
592        ├── [0]: vortex.list, dtype: list(i32), children: 2
593        │   ├── elements: vortex.flat, dtype: i32, segment: 0
594        │   └── offsets: vortex.flat, dtype: u64, segment: 1
595        └── [1]: vortex.list, dtype: list(i32), children: 2
596            ├── elements: vortex.flat, dtype: i32, segment: 2
597            └── offsets: vortex.flat, dtype: u64, segment: 3
598        ");
599        Ok(())
600    }
601}