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