Skip to main content

vortex_layout/layouts/zoned/
mod.rs

1//! Zoned layouts wrap a data layout with an auxiliary per-zone statistics layout.
2//!
3//! The zoned layout tree has exactly two children:
4//! - a transparent `data` child containing the underlying column data
5//! - an auxiliary `zones` child containing one row of aggregate statistics per zone
6//!
7//! Metadata stores the logical zone length in rows plus the aggregate functions present in the
8//! auxiliary table. During scans, pruning first evaluates a falsification predicate against the
9//! `zones` child and only forwards surviving rows to the underlying `data` child.
10
11// SPDX-License-Identifier: Apache-2.0
12// SPDX-FileCopyrightText: Copyright the Vortex contributors
13
14mod builder;
15mod pruning;
16mod reader;
17mod schema;
18pub mod writer;
19pub mod zone_map;
20
21use std::num::NonZeroUsize;
22use std::sync::Arc;
23
24pub(crate) use builder::AggregateStatsAccumulator;
25pub(crate) use builder::aggregate_partials;
26use prost::Message;
27pub use schema::MAX_IS_TRUNCATED;
28pub use schema::MIN_IS_TRUNCATED;
29use vortex_array::DeserializeMetadata;
30use vortex_array::SerializeMetadata;
31use vortex_array::aggregate_fn::AggregateFnRef;
32use vortex_array::dtype::DType;
33use vortex_array::dtype::TryFromBytes;
34use vortex_array::expr::stats::Stat;
35use vortex_array::stats::as_stat_bitset_bytes;
36use vortex_array::stats::stats_from_bitset_bytes;
37use vortex_error::VortexExpect;
38use vortex_error::VortexResult;
39use vortex_error::vortex_bail;
40use vortex_error::vortex_ensure;
41use vortex_error::vortex_ensure_eq;
42use vortex_error::vortex_panic;
43use vortex_session::VortexSession;
44use vortex_session::registry::CachedId;
45
46use crate::LayoutBuildContext;
47use crate::LayoutChildType;
48use crate::LayoutEncodingRef;
49use crate::LayoutId;
50use crate::LayoutReaderRef;
51use crate::LayoutRef;
52use crate::VTable;
53use crate::children::LayoutChildren;
54use crate::children::OwnedLayoutChildren;
55use crate::layouts::zoned::reader::ZonedReader;
56use crate::layouts::zoned::schema::AggregateSpecProto;
57use crate::layouts::zoned::schema::aggregate_specs_from_fns;
58use crate::layouts::zoned::schema::aggregate_stats_table_dtype;
59use crate::layouts::zoned::schema::legacy_stats_table_dtype;
60use crate::layouts::zoned::schema::try_aggregate_fns_from_specs;
61use crate::segments::SegmentId;
62use crate::segments::SegmentSource;
63use crate::vtable;
64
65vtable!(Zoned);
66vtable!(LegacyStats);
67
68/// Builds a reader for a zoned layout, bypassing [`ZonedReader`] when the zone map is empty
69/// (`zone_len == 0`) and reading the data child directly, since there is nothing to prune with.
70/// This covers both legacy zero-length zones and layouts whose aggregates the session cannot
71/// reconstruct.
72fn zoned_reader(
73    layout: &ZonedLayout,
74    name: Arc<str>,
75    segment_source: Arc<dyn SegmentSource>,
76    session: &VortexSession,
77    ctx: &crate::LayoutReaderContext,
78) -> VortexResult<LayoutReaderRef> {
79    if layout.zone_len == 0 {
80        return layout.children.child(0, &layout.dtype)?.new_reader(
81            name,
82            segment_source,
83            session,
84            ctx,
85        );
86    }
87
88    Ok(Arc::new(ZonedReader::try_new(
89        layout.clone(),
90        name,
91        segment_source,
92        session.clone(),
93        ctx.clone(),
94    )?))
95}
96
97impl VTable for Zoned {
98    type Layout = ZonedLayout;
99    type Encoding = ZonedLayoutEncoding;
100    type Metadata = ZonedMetadata;
101
102    fn id(_encoding: &Self::Encoding) -> LayoutId {
103        static ID: CachedId = CachedId::new("vortex.zoned");
104        *ID
105    }
106
107    fn encoding(_layout: &Self::Layout) -> LayoutEncodingRef {
108        LayoutEncodingRef::new_ref(ZonedLayoutEncoding.as_ref())
109    }
110
111    fn row_count(layout: &Self::Layout) -> u64 {
112        layout.children.child_row_count(0)
113    }
114
115    fn dtype(layout: &Self::Layout) -> &DType {
116        &layout.dtype
117    }
118
119    fn metadata(layout: &Self::Layout) -> Self::Metadata {
120        ZonedMetadata {
121            zone_len: u32::try_from(layout.zone_len).vortex_expect("Invalid zone length"),
122            aggregate_specs: match &layout.zone_map_schema {
123                ZoneMapSchema::AggregateFns(aggregate_fns) => {
124                    aggregate_specs_from_fns(aggregate_fns).vortex_expect(
125                        "aggregate functions should be validated as serializable during build",
126                    )
127                }
128                ZoneMapSchema::LegacyStats(_) => {
129                    vortex_panic!("Cannot serialize legacy stats schema as vortex.zoned")
130                }
131            },
132        }
133    }
134
135    fn segment_ids(_layout: &Self::Layout) -> Vec<SegmentId> {
136        vec![]
137    }
138
139    fn nchildren(_layout: &Self::Layout) -> usize {
140        2
141    }
142
143    fn child(layout: &Self::Layout, idx: usize) -> VortexResult<LayoutRef> {
144        match idx {
145            0 => layout.children.child(0, layout.dtype()),
146            1 => layout.children.child(1, &layout.stats_table_dtype),
147            _ => vortex_bail!("Invalid child index: {}", idx),
148        }
149    }
150
151    fn child_type(_layout: &Self::Layout, idx: usize) -> LayoutChildType {
152        match idx {
153            0 => LayoutChildType::Transparent("data".into()),
154            1 => LayoutChildType::Auxiliary("zones".into()),
155            _ => vortex_panic!("Invalid child index: {}", idx),
156        }
157    }
158
159    fn new_reader(
160        layout: &Self::Layout,
161        name: Arc<str>,
162        segment_source: Arc<dyn SegmentSource>,
163        session: &VortexSession,
164        ctx: &crate::LayoutReaderContext,
165    ) -> VortexResult<LayoutReaderRef> {
166        zoned_reader(layout, name, segment_source, session, ctx)
167    }
168
169    fn build(
170        _encoding: &Self::Encoding,
171        dtype: &DType,
172        _row_count: u64,
173        metadata: &ZonedMetadata,
174        _segment_ids: Vec<SegmentId>,
175        children: &dyn LayoutChildren,
176        build_ctx: &LayoutBuildContext<'_>,
177    ) -> VortexResult<Self::Layout> {
178        vortex_ensure_eq!(
179            children.nchildren(),
180            2,
181            "ZonedLayout expects exactly 2 children (data, zones)"
182        );
183
184        // If any stored aggregate is unknown to this session (and unknown plugins are allowed),
185        // disable zoned pruning and fall back to a plain scan of the data child.
186        let Some(aggregate_fns) =
187            try_aggregate_fns_from_specs(&metadata.aggregate_specs, build_ctx.session)?
188        else {
189            return Ok(ZonedLayout {
190                dtype: dtype.clone(),
191                children: children.to_arc(),
192                zone_len: 0,
193                zone_map_schema: ZoneMapSchema::AggregateFns(Arc::new([])),
194                stats_table_dtype: aggregate_stats_table_dtype(dtype, &[]),
195            });
196        };
197
198        // Verify that every aggregate is serializable.
199        aggregate_specs_from_fns(&aggregate_fns)?;
200        let stats_table_dtype = aggregate_stats_table_dtype(dtype, &aggregate_fns);
201
202        Ok(ZonedLayout {
203            dtype: dtype.clone(),
204            children: children.to_arc(),
205            zone_len: metadata.zone_len as usize,
206            zone_map_schema: ZoneMapSchema::AggregateFns(aggregate_fns),
207            stats_table_dtype,
208        })
209    }
210
211    fn with_children(layout: &mut Self::Layout, children: Vec<LayoutRef>) -> VortexResult<()> {
212        if children.len() != 2 {
213            vortex_bail!(
214                "ZonedLayout expects exactly 2 children (data, zones), got {}",
215                children.len()
216            );
217        }
218        layout.children = OwnedLayoutChildren::layout_children(children);
219        Ok(())
220    }
221}
222
223// TODO: This legacy vtable is only needed until layouts move onto the new vtable structure, where
224// a LayoutPlugin can deserialize legacy `vortex.stats` metadata directly into `vortex.zoned`.
225impl VTable for LegacyStats {
226    type Layout = LegacyStatsLayout;
227    type Encoding = LegacyStatsLayoutEncoding;
228    type Metadata = LegacyStatsMetadata;
229
230    fn id(_encoding: &Self::Encoding) -> LayoutId {
231        static ID: CachedId = CachedId::new("vortex.stats");
232        *ID
233    }
234
235    fn encoding(_layout: &Self::Layout) -> LayoutEncodingRef {
236        LayoutEncodingRef::new_ref(LegacyStatsLayoutEncoding.as_ref())
237    }
238
239    fn row_count(layout: &Self::Layout) -> u64 {
240        <Zoned as VTable>::row_count(&layout.0)
241    }
242
243    fn dtype(layout: &Self::Layout) -> &DType {
244        <Zoned as VTable>::dtype(&layout.0)
245    }
246
247    fn metadata(layout: &Self::Layout) -> Self::Metadata {
248        LegacyStatsMetadata {
249            zone_len: u32::try_from(layout.0.zone_len).vortex_expect("Invalid zone length"),
250            zone_map_schema: layout.0.zone_map_schema.clone(),
251        }
252    }
253
254    fn segment_ids(layout: &Self::Layout) -> Vec<SegmentId> {
255        <Zoned as VTable>::segment_ids(&layout.0)
256    }
257
258    fn nchildren(layout: &Self::Layout) -> usize {
259        <Zoned as VTable>::nchildren(&layout.0)
260    }
261
262    fn child(layout: &Self::Layout, idx: usize) -> VortexResult<LayoutRef> {
263        <Zoned as VTable>::child(&layout.0, idx)
264    }
265
266    fn child_type(layout: &Self::Layout, idx: usize) -> LayoutChildType {
267        <Zoned as VTable>::child_type(&layout.0, idx)
268    }
269
270    fn new_reader(
271        layout: &Self::Layout,
272        name: Arc<str>,
273        segment_source: Arc<dyn SegmentSource>,
274        session: &VortexSession,
275        ctx: &crate::LayoutReaderContext,
276    ) -> VortexResult<LayoutReaderRef> {
277        zoned_reader(&layout.0, name, segment_source, session, ctx)
278    }
279
280    fn build(
281        _encoding: &Self::Encoding,
282        dtype: &DType,
283        _row_count: u64,
284        metadata: &LegacyStatsMetadata,
285        _segment_ids: Vec<SegmentId>,
286        children: &dyn LayoutChildren,
287        _build_ctx: &LayoutBuildContext<'_>,
288    ) -> VortexResult<Self::Layout> {
289        vortex_ensure_eq!(
290            children.nchildren(),
291            2,
292            "LegacyStatsLayout expects exactly 2 children (data, zones)"
293        );
294        let stats_table_dtype = match &metadata.zone_map_schema {
295            ZoneMapSchema::LegacyStats(stats) => legacy_stats_table_dtype(dtype, stats),
296            ZoneMapSchema::AggregateFns(aggregate_fns) => {
297                aggregate_stats_table_dtype(dtype, aggregate_fns)
298            }
299        };
300        Ok(LegacyStatsLayout(ZonedLayout {
301            dtype: dtype.clone(),
302            children: children.to_arc(),
303            zone_len: metadata.zone_len as usize,
304            zone_map_schema: metadata.zone_map_schema.clone(),
305            stats_table_dtype,
306        }))
307    }
308
309    fn with_children(layout: &mut Self::Layout, children: Vec<LayoutRef>) -> VortexResult<()> {
310        <Zoned as VTable>::with_children(&mut layout.0, children)
311    }
312}
313
314/// Encoding marker for the zoned layout.
315#[derive(Debug)]
316pub struct ZonedLayoutEncoding;
317
318/// Encoding marker for the legacy `vortex.stats` zoned layout.
319#[derive(Debug)]
320pub struct LegacyStatsLayoutEncoding;
321
322/// A layout that annotates a data child with one row of aggregate statistics per zone.
323///
324/// The first child is the underlying data layout. The second child is an auxiliary stats table
325/// whose rows align with logical row zones of length `zone_len`, except for the final partial zone.
326/// During reads, pruning uses the stats table to skip zones whose rows cannot satisfy a filter.
327#[derive(Clone, Debug)]
328pub struct ZonedLayout {
329    dtype: DType,
330    children: Arc<dyn LayoutChildren>,
331    zone_len: usize,
332    zone_map_schema: ZoneMapSchema,
333    stats_table_dtype: DType,
334}
335
336/// A legacy `vortex.stats` layout backed by the shared zoned runtime implementation.
337#[derive(Clone, Debug)]
338pub struct LegacyStatsLayout(ZonedLayout);
339
340impl LegacyStatsLayout {
341    /// Returns display names for the zone-map aggregates stored by this layout.
342    pub fn present_aggregates(&self) -> Arc<[String]> {
343        self.0.present_aggregates()
344    }
345}
346
347#[derive(Clone, Debug, PartialEq, Eq)]
348pub(crate) enum ZoneMapSchema {
349    LegacyStats(Arc<[Stat]>),
350    AggregateFns(Arc<[AggregateFnRef]>),
351}
352
353impl ZonedLayout {
354    /// Create a zoned layout from a data child, a zone-map child, a zone length, and the aggregate
355    /// functions stored in the zone map.
356    pub fn try_new(
357        data: LayoutRef,
358        zones: LayoutRef,
359        zone_len: NonZeroUsize,
360        aggregate_fns: Arc<[AggregateFnRef]>,
361    ) -> VortexResult<Self> {
362        let expected_dtype = aggregate_stats_table_dtype(data.dtype(), &aggregate_fns);
363        if zones.dtype() != &expected_dtype {
364            vortex_bail!("Invalid zone map layout: zones dtype does not match expected dtype");
365        }
366
367        // Verify that every aggregate is serializable.
368        aggregate_specs_from_fns(&aggregate_fns)?;
369
370        Ok(Self {
371            dtype: data.dtype().clone(),
372            children: OwnedLayoutChildren::layout_children(vec![data, zones]),
373            zone_len: zone_len.get(),
374            zone_map_schema: ZoneMapSchema::AggregateFns(aggregate_fns),
375            stats_table_dtype: expected_dtype,
376        })
377    }
378
379    pub fn nzones(&self) -> usize {
380        usize::try_from(self.children.child_row_count(1)).vortex_expect("Invalid number of zones")
381    }
382
383    pub fn zone_len(&self) -> usize {
384        self.zone_len
385    }
386
387    /// Returns display names for the zone-map aggregates stored by this layout.
388    pub fn present_aggregates(&self) -> Arc<[String]> {
389        match &self.zone_map_schema {
390            ZoneMapSchema::LegacyStats(stats) => stats
391                .iter()
392                .filter_map(Stat::aggregate_fn)
393                .map(|aggregate_fn| aggregate_fn.to_string())
394                .collect::<Vec<_>>()
395                .into(),
396            ZoneMapSchema::AggregateFns(aggregate_fns) => aggregate_fns
397                .iter()
398                .map(ToString::to_string)
399                .collect::<Vec<_>>()
400                .into(),
401        }
402    }
403
404    pub(super) fn aggregate_fns(
405        &self,
406        _session: &VortexSession,
407    ) -> VortexResult<Arc<[AggregateFnRef]>> {
408        match &self.zone_map_schema {
409            ZoneMapSchema::LegacyStats(stats) => Ok(stats
410                .iter()
411                .filter_map(Stat::aggregate_fn)
412                .collect::<Vec<_>>()
413                .into()),
414            ZoneMapSchema::AggregateFns(aggregate_fns) => Ok(Arc::clone(aggregate_fns)),
415        }
416    }
417
418    pub(super) fn stats_table_dtype_for(&self, aggregate_fns: &[AggregateFnRef]) -> DType {
419        if let ZoneMapSchema::LegacyStats(stats) = &self.zone_map_schema {
420            return legacy_stats_table_dtype(&self.dtype, stats);
421        }
422
423        aggregate_stats_table_dtype(&self.dtype, aggregate_fns)
424    }
425}
426
427/// Serialized zoned-layout metadata.
428///
429/// `zone_len` is the logical row length of each zone. `aggregate_specs` is the ordered list of
430/// aggregate functions stored in the auxiliary stats-table child.
431#[derive(Debug, PartialEq, Eq, Clone)]
432pub struct ZonedMetadata {
433    pub(super) zone_len: u32,
434    pub(super) aggregate_specs: Arc<[AggregateSpecProto]>,
435}
436
437/// Serialized metadata for legacy `vortex.stats` layouts.
438#[derive(Debug, PartialEq, Eq, Clone)]
439pub struct LegacyStatsMetadata {
440    pub(super) zone_len: u32,
441    pub(crate) zone_map_schema: ZoneMapSchema,
442}
443
444const ZONED_METADATA_PROTO_VERSION: u8 = 1;
445
446#[derive(Clone, PartialEq, Message)]
447struct ZonedMetadataProto {
448    #[prost(uint32, tag = "1")]
449    zone_len: u32,
450    #[prost(message, repeated, tag = "2")]
451    aggregate_specs: Vec<AggregateSpecProto>,
452}
453
454impl DeserializeMetadata for ZonedMetadata {
455    type Output = Self;
456
457    fn deserialize(metadata: &[u8]) -> VortexResult<Self::Output> {
458        let Some((&version, proto_bytes)) = metadata.split_first() else {
459            vortex_bail!("Zoned metadata missing protobuf version");
460        };
461
462        vortex_ensure!(
463            version == ZONED_METADATA_PROTO_VERSION,
464            "Unsupported zoned metadata version: {}",
465            version
466        );
467        vortex_ensure!(!proto_bytes.is_empty(), "Zoned metadata missing protobuf");
468
469        let proto = ZonedMetadataProto::decode(proto_bytes)?;
470        Ok(Self {
471            zone_len: proto.zone_len,
472            aggregate_specs: proto.aggregate_specs.into(),
473        })
474    }
475}
476
477impl SerializeMetadata for ZonedMetadata {
478    fn serialize(self) -> Vec<u8> {
479        let proto = ZonedMetadataProto {
480            zone_len: self.zone_len,
481            aggregate_specs: self.aggregate_specs.to_vec(),
482        };
483        let mut metadata = vec![ZONED_METADATA_PROTO_VERSION];
484        metadata.extend(proto.encode_to_vec());
485        metadata
486    }
487}
488
489impl DeserializeMetadata for LegacyStatsMetadata {
490    type Output = Self;
491
492    fn deserialize(metadata: &[u8]) -> VortexResult<Self::Output> {
493        vortex_ensure!(
494            metadata.len() >= 4,
495            "Legacy zoned metadata must contain at least 4 bytes for zone length, got {}",
496            metadata.len()
497        );
498
499        // Backward compat: older files may encode `zone_len == 0`. Preserve the raw metadata on
500        // read and let the reader disable zoned pruning for those layouts instead of rejecting
501        // deserialization outright.
502        let zone_len = u32::try_from_le_bytes(&metadata[0..4])?;
503        let present_stats: Arc<[Stat]> = stats_from_bitset_bytes(&metadata[4..]).into();
504
505        Ok(Self {
506            zone_len,
507            zone_map_schema: ZoneMapSchema::LegacyStats(present_stats),
508        })
509    }
510}
511
512impl SerializeMetadata for LegacyStatsMetadata {
513    fn serialize(self) -> Vec<u8> {
514        match self.zone_map_schema {
515            ZoneMapSchema::LegacyStats(stats) => {
516                let mut metadata = self.zone_len.to_le_bytes().to_vec();
517                metadata.extend(as_stat_bitset_bytes(&stats));
518                metadata
519            }
520            ZoneMapSchema::AggregateFns(_) => {
521                vortex_panic!("Cannot serialize aggregate specs as legacy stats metadata")
522            }
523        }
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use std::panic;
530
531    use rstest::rstest;
532    use vortex_array::aggregate_fn::AggregateFnRef;
533    use vortex_array::aggregate_fn::AggregateFnVTableExt;
534    use vortex_array::aggregate_fn::NumericalAggregateOpts;
535    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax;
536    use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions;
537    use vortex_array::aggregate_fn::fns::max::Max;
538    use vortex_array::aggregate_fn::fns::min::Min;
539    use vortex_array::aggregate_fn::session::AggregateFnSession;
540    use vortex_array::dtype::DType;
541    use vortex_array::dtype::Nullability;
542    use vortex_array::dtype::PType;
543    use vortex_array::stats::as_stat_bitset_bytes;
544    use vortex_session::VortexSession;
545    use vortex_session::registry::ReadContext;
546
547    use super::*;
548    use crate::IntoLayout;
549    use crate::children::OwnedLayoutChildren;
550    use crate::layouts::flat::FlatLayout;
551    use crate::segments::SegmentId;
552
553    fn aggregate_spec(aggregate_fn: AggregateFnRef) -> AggregateSpecProto {
554        AggregateSpecProto::try_from_aggregate_fn(&aggregate_fn).unwrap()
555    }
556
557    #[rstest]
558    #[case(ZonedMetadata {
559            zone_len: u32::MAX,
560            aggregate_specs: Arc::new([]),
561        })]
562    #[case::min_max(ZonedMetadata {
563            zone_len: 314,
564            aggregate_specs: Arc::new([
565                aggregate_spec(Max.bind(NumericalAggregateOpts::skip_nans())),
566                aggregate_spec(Min.bind(NumericalAggregateOpts::skip_nans())),
567            ]),
568        })]
569    fn test_metadata_serialization(#[case] metadata: ZonedMetadata) {
570        let serialized = metadata.clone().serialize();
571        assert_eq!(serialized[0], ZONED_METADATA_PROTO_VERSION);
572        let deserialized = ZonedMetadata::deserialize(&serialized).unwrap();
573        assert_eq!(deserialized, metadata);
574    }
575
576    #[test]
577    fn test_metadata_serialization_preserves_aggregate_options() -> VortexResult<()> {
578        let aggregate_fn = BoundedMax.bind(BoundedMaxOptions {
579            // SAFETY: 128 is non-zero.
580            max_bytes: unsafe { NonZeroUsize::new_unchecked(128) },
581        });
582        let metadata = ZonedMetadata {
583            zone_len: 314,
584            aggregate_specs: Arc::new([AggregateSpecProto::try_from_aggregate_fn(&aggregate_fn)?]),
585        };
586
587        let deserialized = ZonedMetadata::deserialize(&metadata.serialize())?;
588        let session = VortexSession::empty().with::<AggregateFnSession>();
589        let aggregate_fns = try_aggregate_fns_from_specs(&deserialized.aggregate_specs, &session)?
590            .expect("known aggregates resolve");
591
592        assert_eq!(aggregate_fns.as_ref(), std::slice::from_ref(&aggregate_fn));
593        Ok(())
594    }
595
596    #[test]
597    fn test_deserialize_legacy_stat_bitset_as_legacy_stats() {
598        let mut serialized = u32::MAX.to_le_bytes().to_vec();
599        serialized.extend(as_stat_bitset_bytes(&[
600            Stat::IsStrictSorted,
601            Stat::IsSorted,
602            Stat::Max,
603        ]));
604        let deserialized = LegacyStatsMetadata::deserialize(&serialized).unwrap();
605        let ZoneMapSchema::LegacyStats(legacy_stats) = deserialized.zone_map_schema else {
606            panic!("legacy bitset metadata should deserialize as legacy stats");
607        };
608
609        assert!(legacy_stats.is_sorted());
610        assert_eq!(
611            legacy_stats.as_ref(),
612            &[Stat::IsSorted, Stat::IsStrictSorted, Stat::Max]
613        );
614    }
615
616    #[rstest]
617    #[case::empty(vec![])]
618    #[case::unsupported_version(vec![0])]
619    #[case::missing_proto(vec![ZONED_METADATA_PROTO_VERSION])]
620    #[case::malformed_proto(vec![ZONED_METADATA_PROTO_VERSION, 0])]
621    fn test_deserialize_short_metadata_errors(#[case] metadata: Vec<u8>) {
622        assert!(ZonedMetadata::deserialize(&metadata).is_err());
623    }
624
625    #[test]
626    fn test_deserialize_short_metadata_returns_error_not_panic() {
627        let result = panic::catch_unwind(|| ZonedMetadata::deserialize(&[]));
628        assert!(
629            result.is_ok(),
630            "deserialize should return an error, not panic"
631        );
632        assert!(result.unwrap().is_err());
633    }
634
635    #[test]
636    fn test_deserialize_zero_zone_len_is_allowed_for_backcompat() {
637        let metadata = 0u32.to_le_bytes();
638        let deserialized = LegacyStatsMetadata::deserialize(&metadata).unwrap();
639        assert_eq!(deserialized.zone_len, 0);
640        let ZoneMapSchema::LegacyStats(legacy_stats) = deserialized.zone_map_schema else {
641            panic!("legacy bitset metadata should deserialize as legacy stats");
642        };
643        assert!(legacy_stats.is_empty());
644    }
645
646    #[test]
647    fn test_build_allows_zero_zone_len_for_backcompat() -> VortexResult<()> {
648        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
649        let read_ctx = ReadContext::new([]);
650        let children = OwnedLayoutChildren::layout_children(vec![
651            FlatLayout::new(0, dtype.clone(), SegmentId::from(0), read_ctx.clone()).into_layout(),
652            FlatLayout::new(
653                0,
654                legacy_stats_table_dtype(&dtype, &[]),
655                SegmentId::from(1),
656                read_ctx,
657            )
658            .into_layout(),
659        ]);
660        let session = vortex_array::array_session();
661        let build_read_ctx = ReadContext::new([]);
662        let build_ctx = LayoutBuildContext {
663            session: &session,
664            array_read_ctx: &build_read_ctx,
665        };
666
667        let layout = <LegacyStats as VTable>::build(
668            &LegacyStatsLayoutEncoding,
669            &dtype,
670            0,
671            &LegacyStatsMetadata {
672                zone_len: 0,
673                zone_map_schema: ZoneMapSchema::LegacyStats(Arc::new([])),
674            },
675            vec![],
676            children.as_ref(),
677            &build_ctx,
678        )?;
679
680        assert_eq!(layout.0.zone_len, 0);
681        Ok(())
682    }
683
684    #[test]
685    fn test_build_rejects_invalid_child_count() {
686        let metadata = ZonedMetadata {
687            zone_len: 3,
688            aggregate_specs: Arc::new([]),
689        };
690        let children = OwnedLayoutChildren::layout_children(vec![]);
691        let session = vortex_array::array_session();
692        let build_read_ctx = ReadContext::new([]);
693        let build_ctx = LayoutBuildContext {
694            session: &session,
695            array_read_ctx: &build_read_ctx,
696        };
697
698        let result = <Zoned as VTable>::build(
699            &ZonedLayoutEncoding,
700            &DType::Primitive(PType::I32, Nullability::NonNullable),
701            0,
702            &metadata,
703            vec![],
704            children.as_ref(),
705            &build_ctx,
706        );
707
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn test_build_unknown_aggregate_disables_pruning_when_allowed() -> VortexResult<()> {
713        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
714        let read_ctx = ReadContext::new([]);
715        let children = OwnedLayoutChildren::layout_children(vec![
716            FlatLayout::new(0, dtype.clone(), SegmentId::from(0), read_ctx.clone()).into_layout(),
717            FlatLayout::new(0, dtype.clone(), SegmentId::from(1), read_ctx).into_layout(),
718        ]);
719        let session = vortex_array::array_session();
720        session.allow_unknown();
721        let build_read_ctx = ReadContext::new([]);
722        let build_ctx = LayoutBuildContext {
723            session: &session,
724            array_read_ctx: &build_read_ctx,
725        };
726
727        let metadata = ZonedMetadata {
728            zone_len: 8,
729            aggregate_specs: Arc::new([AggregateSpecProto::new_unknown("vortex.test.unknown")]),
730        };
731
732        let layout = <Zoned as VTable>::build(
733            &ZonedLayoutEncoding,
734            &dtype,
735            0,
736            &metadata,
737            vec![],
738            children.as_ref(),
739            &build_ctx,
740        )?;
741
742        // An unknown aggregate disables zoned pruning (`zone_len == 0`) while leaving the data
743        // child readable, so the index is ignored rather than turned into a hard read error.
744        assert_eq!(layout.zone_len, 0);
745        Ok(())
746    }
747
748    #[test]
749    fn test_build_unknown_aggregate_errors_without_allow_unknown() {
750        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
751        let read_ctx = ReadContext::new([]);
752        let children = OwnedLayoutChildren::layout_children(vec![
753            FlatLayout::new(0, dtype.clone(), SegmentId::from(0), read_ctx.clone()).into_layout(),
754            FlatLayout::new(0, dtype.clone(), SegmentId::from(1), read_ctx).into_layout(),
755        ]);
756        let session = vortex_array::array_session();
757        let build_read_ctx = ReadContext::new([]);
758        let build_ctx = LayoutBuildContext {
759            session: &session,
760            array_read_ctx: &build_read_ctx,
761        };
762
763        let metadata = ZonedMetadata {
764            zone_len: 8,
765            aggregate_specs: Arc::new([AggregateSpecProto::new_unknown("vortex.test.unknown")]),
766        };
767
768        let result = <Zoned as VTable>::build(
769            &ZonedLayoutEncoding,
770            &dtype,
771            0,
772            &metadata,
773            vec![],
774            children.as_ref(),
775            &build_ctx,
776        );
777
778        assert!(result.is_err());
779    }
780}