Skip to main content

icechunk_format/
manifest.rs

1//! Chunk reference tables mapping coordinates to storage locations.
2
3use std::{
4    borrow::Cow,
5    cmp::{max, min},
6    iter::zip,
7    ops::Range,
8    string::FromUtf8Error,
9    sync::Arc,
10};
11
12use crate::flatbuffers::generated;
13use bytes::Bytes;
14use flatbuffers::VerifierOptions;
15use futures::{Stream, TryStreamExt as _};
16use itertools::{Itertools as _, any, multiunzip};
17use rand::{RngExt as _, rngs::SmallRng};
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21use crate::{IcechunkFormatError, IcechunkFormatErrorKind};
22use icechunk_types::{ETag, error::ICError};
23
24/// Resolved configuration for virtual chunk location compression within a manifest.
25#[derive(Debug, Clone, Copy)]
26pub struct LocationCompressionConfig {
27    pub min_num_chunks: u16,
28    pub dictionary_max_training_samples: u16,
29    pub dictionary_max_size_bytes: u32,
30    pub compression_level: i32,
31}
32
33use crate::{
34    ChunkId, ChunkIndices, ChunkLength, ChunkOffset, IcechunkResult, ManifestId, NodeId,
35};
36use icechunk_types::ICResultExt as _;
37
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum Overlap {
40    Complete,
41    Partial,
42    None,
43}
44
45#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ManifestExtents(Vec<Range<u32>>);
47
48impl ManifestExtents {
49    // sentinel for a "universal set"
50    pub const ALL: Self = Self(Vec::new());
51
52    pub fn new(from: &[u32], to: &[u32]) -> Self {
53        let v = from
54            .iter()
55            .zip(to.iter())
56            .map(|(a, b)| Range { start: *a, end: *b })
57            .collect();
58        Self(v)
59    }
60
61    pub fn from_ranges_iter(ranges: impl IntoIterator<Item = Range<u32>>) -> Self {
62        Self(ranges.into_iter().collect())
63    }
64
65    #[inline(always)]
66    pub fn contains(&self, coord: &[u32]) -> bool {
67        self.iter().zip(coord.iter()).all(|(range, that)| range.contains(that))
68    }
69
70    pub fn iter(&self) -> impl Iterator<Item = &Range<u32>> {
71        self.0.iter()
72    }
73
74    pub fn len(&self) -> usize {
75        self.0.len()
76    }
77
78    pub fn is_empty(&self) -> bool {
79        self.0.is_empty()
80    }
81
82    pub fn intersection(&self, other: &Self) -> Option<Self> {
83        if self == &Self::ALL {
84            return Some(other.clone());
85        }
86
87        debug_assert_eq!(self.len(), other.len());
88        let ranges = zip(self.iter(), other.iter())
89            .map(|(a, b)| max(a.start, b.start)..min(a.end, b.end))
90            .collect::<Vec<_>>();
91        if any(ranges.iter(), |r| r.end <= r.start) { None } else { Some(Self(ranges)) }
92    }
93
94    pub fn union(&self, other: &Self) -> Self {
95        if self == &Self::ALL {
96            return Self::ALL;
97        }
98        debug_assert_eq!(self.len(), other.len());
99        Self::from_ranges_iter(
100            zip(self.iter(), other.iter())
101                .map(|(a, b)| min(a.start, b.start)..max(a.end, b.end)),
102        )
103    }
104
105    pub fn overlap_with(&self, other: &Self) -> Overlap {
106        // Important: this is not symmetric.
107        if *other == Self::ALL {
108            return Overlap::Complete;
109        } else if *self == Self::ALL {
110            return Overlap::Partial;
111        }
112        debug_assert!(
113            self.len() == other.len(),
114            "Length mismatch: self = {:?}, other = {:?}",
115            &self,
116            &other
117        );
118        let mut overlap = Overlap::Complete;
119        for (a, b) in zip(other.iter(), self.iter()) {
120            debug_assert!(a.start <= a.end, "Invalid range: {:?}", a.clone());
121            debug_assert!(b.start <= b.end, "Invalid range: {:?}", b.clone());
122            if (a.end <= b.start) || (a.start >= b.end) {
123                return Overlap::None;
124            } else if !((a.start <= b.start) && (a.end >= b.end)) {
125                overlap = Overlap::Partial;
126            }
127        }
128        overlap
129    }
130
131    pub fn matches(&self, other: &ManifestExtents) -> bool {
132        // used in `.filter`
133        // ALL always matches any other extents
134        if *self == Self::ALL { true } else { self == other }
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct ManifestRef {
140    pub object_id: ManifestId,
141    pub extents: ManifestExtents,
142}
143
144// ManifestSplits can be constructed from a iterable of shard edges or boundaries
145// along each dimension.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct ManifestSplits(pub Vec<Vec<u32>>);
148
149impl ManifestSplits {
150    pub fn is_empty(&self) -> bool {
151        self.0.is_empty()
152    }
153
154    pub fn from_edges(iter: impl IntoIterator<Item = Vec<u32>>) -> Self {
155        Self(iter.into_iter().collect())
156    }
157
158    pub fn iter(&self) -> impl Iterator<Item = ManifestExtents> {
159        self.0
160            .iter()
161            // assume
162            // vec![vec![0u32, 1, 2], vec![3u32, 4, 5]]
163            .cloned()
164            // vec![(0, 1), (1, 2)], vec![(3, 4), (4, 5)]
165            .map(|x| x.into_iter().tuple_windows())
166            // vec![((0, 1), (3, 4)), ((0, 1), (4, 5)),
167            //      ((1, 2), (3, 4)), ((1, 2), (4, 5))]
168            .multi_cartesian_product()
169            // vec![((0, 3), (1, 4)), ((0, 4), (1, 5)),
170            //      ((1, 3), (2, 4)), ((1, 4), (2, 5))]
171            .map(multiunzip)
172            .map(|(from, to): (Vec<u32>, Vec<u32>)| {
173                ManifestExtents::new(from.as_slice(), to.as_slice())
174            })
175    }
176
177    pub fn len(&self) -> usize {
178        self.0.len()
179    }
180
181    /// Binary search to locate `ManifestExtents` for a given chunk coordinate.
182    #[inline(always)]
183    pub fn find<'a>(&'a self, coord: &'a ChunkIndices) -> Option<ManifestExtents> {
184        debug_assert_eq!(coord.0.len(), self.0.len());
185        let mut ranges = Vec::with_capacity(self.0.len());
186        for (edges, loc) in self.0.iter().zip(coord.0.iter()) {
187            let bin = edges.partition_point(|&e| e <= *loc);
188            if bin == 0 || bin >= edges.len() {
189                return None;
190            }
191            ranges.push(edges[bin - 1]..edges[bin]);
192        }
193        Some(ManifestExtents::from_ranges_iter(ranges))
194    }
195
196    pub fn compatible_with(&self, other: &Self) -> bool {
197        // this is not a simple zip + all(equals) because
198        // ordering might differ though both sets of splits
199        // must be complete.
200        for ours in self.iter() {
201            if any(other.iter(), |theirs| {
202                ours.overlap_with(&theirs) == Overlap::Partial
203                    || theirs.overlap_with(&ours) == Overlap::Partial
204            }) {
205                return false;
206            }
207        }
208        true
209    }
210}
211
212/// Helper function for constructing uniformly spaced manifest split edges
213pub fn uniform_manifest_split_edges(num_chunks: u32, split_size: &u32) -> Vec<u32> {
214    (0u32..=num_chunks)
215        .step_by(*split_size as usize)
216        .chain((!num_chunks.is_multiple_of(*split_size)).then_some(num_chunks))
217        .collect()
218}
219
220#[derive(Debug, Error)]
221#[non_exhaustive]
222pub enum VirtualReferenceErrorKind {
223    #[error(
224        "no virtual chunk container can handle the chunk location ({0}), edit the repository configuration adding a virtual chunk container for the chunk references, see https://icechunk.io/en/stable/virtual/"
225    )]
226    NoContainerForUrl(String),
227    #[error("error parsing virtual ref URL: {url:?}")]
228    CannotParseUrl {
229        #[source]
230        cause: url::ParseError,
231        url: String,
232    },
233    #[error("invalid credentials for virtual reference of type {0}")]
234    InvalidCredentials(String),
235    #[error("{}", format_unauthorized_vcc(.url_prefix, .name))]
236    UnauthorizedVirtualChunkContainer { url_prefix: String, name: Option<String> },
237    #[error("virtual reference has no path segments {0}")]
238    NoPathSegments(String),
239    #[error("unsupported scheme for virtual chunk refs: {0}")]
240    UnsupportedScheme(String),
241    #[error("error parsing bucket name from virtual ref URL {0}")]
242    CannotParseBucketName(String),
243    #[error("error fetching virtual reference")]
244    FetchError(#[source] Box<dyn std::error::Error + Send + Sync>),
245    #[error("the checksum of the object owning the virtual chunk has changed ({0})")]
246    ObjectModified(String),
247    #[error(
248        "error retrieving virtual chunk, not enough data. Expected: ({expected}), available ({available})"
249    )]
250    InvalidObjectSize { expected: u64, available: u64 },
251    #[error("azure store configuration must include an account")]
252    AzureConfigurationMustIncludeAccount,
253    #[error("decoding virtual chunk url")]
254    Decoding(#[from] FromUtf8Error),
255    #[error(
256        "no virtual chunk container named '{0}' found, check the repository configuration"
257    )]
258    NoContainerForName(String),
259    #[error("unknown error")]
260    OtherError(#[from] Box<dyn std::error::Error + Send + Sync>),
261}
262
263fn format_unauthorized_vcc(url_prefix: &str, name: &Option<String>) -> String {
264    let container = match name {
265        Some(n) => format!(" (container: {n})"),
266        None => String::new(),
267    };
268    format!(
269        "a virtual chunk in this repository resolves to the url prefix {url_prefix}{container}, \
270         to be able to fetch the chunk you need to authorize the virtual chunk container \
271         when you open/create the repository, see https://icechunk.io/en/stable/virtual/"
272    )
273}
274
275pub type VirtualReferenceError = ICError<VirtualReferenceErrorKind>;
276
277pub const VCC_RELATIVE_URL_SCHEME: &str = "vcc://";
278
279#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
280pub struct VirtualChunkLocation(String);
281
282impl VirtualChunkLocation {
283    pub fn url(&self) -> &str {
284        self.0.as_str()
285    }
286
287    /// Wrap a pre-validated location string without re-parsing.
288    fn from_trusted(s: String) -> Self {
289        VirtualChunkLocation(s)
290    }
291
292    /// Returns true if this is a relative `vcc://` location.
293    pub fn is_relative(&self) -> bool {
294        self.0.starts_with(VCC_RELATIVE_URL_SCHEME)
295    }
296
297    /// If this is a `vcc://name/path` location, returns `(name, relative_path)`.
298    pub fn parse_vcc(&self) -> Option<(&str, &str)> {
299        let rest = self.0.strip_prefix(VCC_RELATIVE_URL_SCHEME)?;
300        let slash = rest.find('/')?;
301        Some((&rest[..slash], &rest[slash + 1..]))
302    }
303
304    /// Creates a relative location from a VCC name and a path relative to its prefix.
305    pub fn from_vcc_path(
306        container_name: &str,
307        relative_path: &str,
308    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
309        if container_name.is_empty() || container_name.contains('/') {
310            return Err(VirtualReferenceError::capture(
311                VirtualReferenceErrorKind::NoContainerForName(container_name.to_string()),
312            ));
313        }
314        let mut result = String::with_capacity(
315            VCC_RELATIVE_URL_SCHEME.len()
316                + container_name.len()
317                + 1
318                + relative_path.len(),
319        );
320        result.push_str(VCC_RELATIVE_URL_SCHEME);
321        result.push_str(container_name);
322        for segment in relative_path.split('/').filter(|s| !s.is_empty()) {
323            result.push('/');
324            result.push_str(segment);
325        }
326        Ok(VirtualChunkLocation(result))
327    }
328
329    /// Parse a location that may be either `vcc://` relative or an absolute URL.
330    pub fn from_url(path: &str) -> Result<VirtualChunkLocation, VirtualReferenceError> {
331        // vcc:// URLs are valid URL scheme, so from_absolute_path handles both
332        // absolute (s3://, gcs://, file://) and relative (vcc://) URLs correctly.
333        Self::from_absolute_path(path)
334    }
335
336    fn from_absolute_path(
337        path: &str,
338    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
339        // make sure we can parse the provided URL before creating the enum
340        // TODO: consider other validation here.
341        let url = url::Url::parse(path)
342            .map_err(|e| VirtualReferenceErrorKind::CannotParseUrl {
343                cause: e,
344                url: path.to_string(),
345            })
346            .capture()?;
347        let scheme = url.scheme();
348        let segments = url
349            .path_segments()
350            .ok_or_else(|| VirtualReferenceErrorKind::NoPathSegments(path.into()))
351            .capture()?;
352
353        let host = if let Some(host) = url.host_str() {
354            host
355        } else if scheme == "file" {
356            ""
357        } else if scheme == "vcc" {
358            return Err(VirtualReferenceError::capture(
359                VirtualReferenceErrorKind::NoContainerForName(path.into()),
360            ));
361        } else {
362            return Err(VirtualReferenceError::capture(
363                VirtualReferenceErrorKind::CannotParseBucketName(path.into()),
364            ));
365        };
366
367        let mut result = String::with_capacity(path.len());
368        result.push_str(scheme);
369        result.push_str("://");
370        result.push_str(host);
371        result.push('/');
372        let mut sep = "";
373        for segment in segments.filter(|x| !x.is_empty()) {
374            result.push_str(sep);
375            result.push_str(segment);
376            sep = "/";
377        }
378
379        Ok(VirtualChunkLocation(result))
380    }
381}
382
383#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
384pub struct SecondsSinceEpoch(pub u32);
385
386#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
387pub enum Checksum {
388    LastModified(SecondsSinceEpoch),
389    ETag(ETag),
390}
391
392#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
393pub struct VirtualChunkRef {
394    pub location: VirtualChunkLocation,
395    pub offset: ChunkOffset,
396    pub length: ChunkLength,
397    pub checksum: Option<Checksum>,
398}
399
400#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
401pub struct ChunkRef {
402    pub id: ChunkId,
403    pub offset: ChunkOffset,
404    pub length: ChunkLength,
405}
406
407#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
408#[non_exhaustive]
409pub enum ChunkPayload {
410    Inline(Bytes),
411    Virtual(VirtualChunkRef),
412    Ref(ChunkRef),
413}
414
415#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
416pub struct ChunkInfo {
417    pub node: NodeId,
418    pub coord: ChunkIndices,
419    pub payload: ChunkPayload,
420}
421
422const COMPRESSION_ALG_NONE: u8 = 0;
423const COMPRESSION_ALG_ZSTD_DICT: u8 = 1;
424// This is the maximum size we support for a virtual chunk url that will be compressed
425const MAX_DECOMPRESSED_LOCATION_SIZE: usize = 1_024;
426
427#[derive(PartialEq)]
428pub struct Manifest {
429    buffer: Vec<u8>,
430}
431
432impl std::fmt::Debug for Manifest {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        f.debug_struct("Manifest")
435            .field("id", &self.id())
436            .field("chunks", &self.len())
437            .finish_non_exhaustive()
438    }
439}
440
441impl Manifest {
442    pub fn id(&self) -> ManifestId {
443        ManifestId::new(self.root().id().0)
444    }
445
446    pub fn bytes(&self) -> &[u8] {
447        self.buffer.as_slice()
448    }
449
450    pub fn from_buffer(buffer: Vec<u8>) -> Result<Manifest, IcechunkFormatError> {
451        let _ = flatbuffers::root_with_opts::<generated::Manifest<'_>>(
452            &ROOT_OPTIONS,
453            buffer.as_slice(),
454        )
455        .capture()?;
456        Ok(Manifest { buffer })
457    }
458
459    /// Create a zstd decompressor from the manifest's location dictionary, if present.
460    fn decompressor(
461        &self,
462    ) -> Result<Option<zstd::bulk::Decompressor<'static>>, IcechunkFormatError> {
463        let root = self.root();
464        if root.compression_algorithm() != COMPRESSION_ALG_ZSTD_DICT {
465            return Ok(None);
466        }
467        match root.location_dictionary() {
468            Some(dict_bytes) => {
469                let decompressor =
470                    zstd::bulk::Decompressor::with_dictionary(dict_bytes.bytes())
471                        .capture()?;
472                Ok(Some(decompressor))
473            }
474            None => Ok(None),
475        }
476    }
477
478    pub fn from_sorted_vec(
479        manifest_id: &ManifestId,
480        sorted_chunks: Vec<ChunkInfo>,
481        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
482    ) -> IcechunkResult<Option<Self>> {
483        let location_compression_dict =
484            train_location_dictionary(&sorted_chunks, virtual_chunks_compression_config)?;
485        // we generate all compressed locations if needed
486        let compressed_locations =
487            match (&location_compression_dict, virtual_chunks_compression_config) {
488                (Some(d), Some(config)) => compress_locations(&sorted_chunks, d, config),
489                _ => vec![None; sorted_chunks.len()],
490            };
491
492        // Sequential FlatBuffer building using pre-compressed locations
493        // TODO: what's a good capacity?
494        let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024);
495
496        let len = sorted_chunks.len();
497        let mut all = sorted_chunks.into_iter().zip(compressed_locations).peekable();
498
499        let mut array_manifests = Vec::with_capacity(1);
500        while let Some(current_node) = all.peek().map(|(chunk, _)| chunk.node.clone()) {
501            // TODO: adjust capacity when multiple arrays have their manifests consolidated in to one.
502            let mut refs = Vec::with_capacity(len);
503            while let Some((chunk, precompressed)) =
504                all.next_if(|(chunk, _)| chunk.node == current_node)
505            {
506                refs.push(mk_chunk_ref(&mut builder, chunk, precompressed));
507            }
508
509            let node_id = Some(generated::ObjectId8::new(&current_node.0));
510            let refs = Some(builder.create_vector(refs.as_slice()));
511            let array_manifest = generated::ArrayManifest::create(
512                &mut builder,
513                &generated::ArrayManifestArgs {
514                    node_id: node_id.as_ref(),
515                    refs,
516                    ..Default::default()
517                },
518            );
519            array_manifests.push(array_manifest);
520        }
521
522        if array_manifests.is_empty() {
523            // empty manifest
524            return Ok(None);
525        }
526
527        let arrays = builder.create_vector(array_manifests.as_slice());
528        let bin_manifest_id = generated::ObjectId12::new(&manifest_id.0);
529
530        let (location_dictionary, compression_algorithm) =
531            if let Some(ref dict) = location_compression_dict {
532                (Some(builder.create_vector(dict.as_slice())), COMPRESSION_ALG_ZSTD_DICT)
533            } else {
534                (None, COMPRESSION_ALG_NONE)
535            };
536
537        let manifest = generated::Manifest::create(
538            &mut builder,
539            &generated::ManifestArgs {
540                id: Some(&bin_manifest_id),
541                arrays: Some(arrays),
542                location_dictionary,
543                compression_algorithm,
544                ..Default::default()
545            },
546        );
547
548        builder.finish(manifest, Some("Ichk"));
549        let (mut buffer, offset) = builder.collapse();
550        buffer.drain(0..offset);
551        buffer.shrink_to_fit();
552        Ok(Some(Manifest { buffer }))
553    }
554
555    pub async fn from_stream<E>(
556        manifest_id: &ManifestId,
557        stream: impl Stream<Item = Result<ChunkInfo, E>>,
558        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
559    ) -> Result<Option<Self>, E>
560    where
561        E: From<IcechunkFormatError>,
562    {
563        let mut all = stream.try_collect::<Vec<_>>().await?;
564        all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord)));
565        Ok(Self::from_sorted_vec(manifest_id, all, virtual_chunks_compression_config)?)
566    }
567
568    /// Used for tests
569    pub async fn from_iter<T: IntoIterator<Item = ChunkInfo>>(
570        manifest_id: &ManifestId,
571        iter: T,
572        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
573    ) -> IcechunkResult<Option<Self>> {
574        Self::from_stream(
575            manifest_id,
576            futures::stream::iter(iter.into_iter().map(Ok::<_, IcechunkFormatError>)),
577            virtual_chunks_compression_config,
578        )
579        .await
580    }
581
582    pub fn len(&self) -> usize {
583        self.root().arrays().iter().map(|am| am.refs().len()).sum()
584    }
585
586    #[must_use]
587    pub fn is_empty(&self) -> bool {
588        self.len() == 0
589    }
590
591    #[expect(unsafe_code)]
592    fn root(&self) -> generated::Manifest<'_> {
593        // SAFETY: self.buffer was serialized by our own flatbuffers serialization code.
594        // We skip validation for performance; a corrupt buffer here indicates
595        // file corruption or a bad Icechunk implementation, not a caller error.
596        unsafe { flatbuffers::root_unchecked::<generated::Manifest<'_>>(&self.buffer) }
597    }
598
599    pub fn arrays(&self) -> impl Iterator<Item = NodeId> {
600        self.root().arrays().iter().map(|am| NodeId::from(am.node_id().0))
601    }
602
603    pub fn uses_location_compression(&self) -> bool {
604        self.root().compression_algorithm() != COMPRESSION_ALG_NONE
605    }
606
607    pub fn location_dictionary_size(&self) -> Option<usize> {
608        self.root().location_dictionary().map(|d| d.len())
609    }
610
611    pub fn num_compressed_refs(&self) -> usize {
612        self.root()
613            .arrays()
614            .iter()
615            .flat_map(|am| am.refs().iter())
616            .filter(|r| r.compressed_location().is_some())
617            .count()
618    }
619
620    pub fn get_chunk_payload(
621        &self,
622        node: &NodeId,
623        coord: &ChunkIndices,
624    ) -> IcechunkResult<ChunkPayload> {
625        let mut decompressor = self.decompressor()?;
626        let manifest = self.root();
627        let chunk_ref = lookup_node(manifest, node)
628            .and_then(|array_manifest| lookup_ref(array_manifest, coord))
629            .ok_or_else(|| IcechunkFormatErrorKind::ChunkCoordinatesNotFound {
630                coords: coord.clone(),
631            })
632            .capture()?;
633        ref_to_payload(chunk_ref, decompressor.as_mut())
634    }
635
636    pub fn iter(
637        self: Arc<Self>,
638        node: NodeId,
639    ) -> Result<
640        impl Iterator<Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>>,
641        IcechunkFormatError,
642    > {
643        PayloadIterator::new(self, node)
644    }
645
646    pub fn chunk_payloads(
647        &self,
648    ) -> Result<
649        impl Iterator<Item = Result<ChunkPayload, IcechunkFormatError>> + '_,
650        IcechunkFormatError,
651    > {
652        let mut decompressor = self.decompressor()?;
653        let refs: Vec<_> =
654            self.root().arrays().iter().flat_map(|am| am.refs().iter()).collect();
655        Ok(refs.into_iter().map(move |r| ref_to_payload(r, decompressor.as_mut())))
656    }
657}
658
659fn lookup_node<'a>(
660    manifest: generated::Manifest<'a>,
661    node: &NodeId,
662) -> Option<generated::ArrayManifest<'a>> {
663    manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id))
664}
665
666fn lookup_ref<'a>(
667    array_manifest: generated::ArrayManifest<'a>,
668    coord: &ChunkIndices,
669) -> Option<generated::ChunkRef<'a>> {
670    array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| {
671        chunk_ref.index().iter().cmp(coords.iter().copied())
672    })
673}
674
675pub struct PayloadIterator {
676    manifest: Arc<Manifest>,
677    node_id: NodeId,
678    last_ref_index: usize,
679    decompressor: Option<zstd::bulk::Decompressor<'static>>,
680}
681
682impl std::fmt::Debug for PayloadIterator {
683    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684        f.debug_struct("PayloadIterator")
685            .field("node_id", &self.node_id)
686            .field("last_ref_index", &self.last_ref_index)
687            .finish_non_exhaustive()
688    }
689}
690
691impl PayloadIterator {
692    fn new(
693        manifest: Arc<Manifest>,
694        node_id: NodeId,
695    ) -> Result<Self, IcechunkFormatError> {
696        let decompressor = manifest.decompressor()?;
697        Ok(Self { manifest, node_id, last_ref_index: 0, decompressor })
698    }
699}
700
701impl Iterator for PayloadIterator {
702    type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>;
703
704    fn next(&mut self) -> Option<Self::Item> {
705        let manifest = self.manifest.root();
706        lookup_node(manifest, &self.node_id).and_then(|array_manifest| {
707            let refs = array_manifest.refs();
708            if self.last_ref_index >= refs.len() {
709                return None;
710            }
711
712            let chunk_ref = refs.get(self.last_ref_index);
713            self.last_ref_index += 1;
714            Some(
715                ref_to_payload(chunk_ref, self.decompressor.as_mut())
716                    .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)),
717            )
718        })
719    }
720}
721
722fn ref_to_payload(
723    chunk_ref: generated::ChunkRef<'_>,
724    decompressor: Option<&mut zstd::bulk::Decompressor<'static>>,
725) -> Result<ChunkPayload, IcechunkFormatError> {
726    if let Some(chunk_id) = chunk_ref.chunk_id() {
727        let id = ChunkId::new(chunk_id.0);
728        Ok(ChunkPayload::Ref(ChunkRef {
729            id,
730            offset: chunk_ref.offset(),
731            length: chunk_ref.length(),
732        }))
733    } else if let Some(compressed) = chunk_ref.compressed_location() {
734        let decompressor = decompressor
735            .ok_or(IcechunkFormatErrorKind::MissingLocationCompressionDictionary)
736            .capture()?;
737        let decompressed = decompressor
738            .decompress(compressed.bytes(), MAX_DECOMPRESSED_LOCATION_SIZE)
739            .capture()?;
740        let location_string = String::from_utf8(decompressed)
741            .map_err(|e| {
742                IcechunkFormatErrorKind::IO(std::io::Error::new(
743                    std::io::ErrorKind::InvalidData,
744                    e,
745                ))
746            })
747            .capture()?;
748        let location = VirtualChunkLocation::from_trusted(location_string);
749        Ok(ChunkPayload::Virtual(VirtualChunkRef {
750            location,
751            checksum: checksum(&chunk_ref),
752            offset: chunk_ref.offset(),
753            length: chunk_ref.length(),
754        }))
755    } else if let Some(location) = chunk_ref.location() {
756        let location = VirtualChunkLocation::from_trusted(location.to_string());
757        Ok(ChunkPayload::Virtual(VirtualChunkRef {
758            location,
759            checksum: checksum(&chunk_ref),
760            offset: chunk_ref.offset(),
761            length: chunk_ref.length(),
762        }))
763    } else if let Some(data) = chunk_ref.inline() {
764        Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
765    } else {
766        Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
767            flatbuffers::InvalidFlatbuffer::InconsistentUnion {
768                field: Cow::Borrowed("chunk_id+location+inline"),
769                field_type: Cow::Borrowed("invalid"),
770                error_trace: Default::default(),
771            },
772        ))
773        .capture()
774    }
775}
776
777fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
778    if let Some(etag) = payload.checksum_etag() {
779        Some(Checksum::ETag(ETag(etag.to_string())))
780    } else if payload.checksum_last_modified() > 0 {
781        Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
782    } else {
783        None
784    }
785}
786
787/// Sample virtual chunk URLs and train a zstd dictionary for compressing them.
788///
789/// Uses reservoir sampling (Algorithm R) to collect a uniform random sample in a single
790/// pass without knowing the total virtual chunk count in advance.
791/// See: <https://en.wikipedia.org/wiki/Reservoir_sampling#Simple>:_`Algorithm_R`
792///
793/// Returns `Some(dict_bytes)` if compression is enabled and there are enough virtual
794/// chunks, `None` if compression is disabled or cannot be executed.
795fn train_location_dictionary(
796    chunks: &[ChunkInfo],
797    virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
798) -> IcechunkResult<Option<Vec<u8>>> {
799    let Some(config) = virtual_chunks_compression_config else {
800        return Ok(None);
801    };
802    let max_samples = config.dictionary_max_training_samples as usize;
803    let min_chunks = config.min_num_chunks as usize;
804    let max_dict_size = config.dictionary_max_size_bytes as usize;
805
806    let mut virtual_count: usize = 0;
807    let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
808    let mut rng: SmallRng = rand::make_rng();
809
810    if chunks.len() < min_chunks {
811        return Ok(None);
812    }
813
814    for chunk in chunks {
815        if let ChunkPayload::Virtual(vref) = &chunk.payload {
816            let loc = vref.location.url();
817            if virtual_count < max_samples {
818                // Fill phase: reservoir not yet full
819                reservoir.push(loc);
820            } else {
821                // Replace phase: include new item with decreasing probability
822                let j = rng.random_range(0..=virtual_count);
823                if j < max_samples {
824                    reservoir[j] = loc;
825                }
826            }
827            virtual_count += 1;
828        }
829    }
830
831    if virtual_count < min_chunks {
832        return Ok(None);
833    }
834
835    let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
836
837    // zstd doesn't like it if many samples are too small, in that case we don't compress
838    let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
839    if small_count >= sample_bytes.len() / 2 {
840        tracing::warn!(
841            "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
842            sample_bytes.len()
843        );
844        return Ok(None);
845    }
846
847    let mut sample_data: Vec<u8> =
848        sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
849    let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
850    let total_sample_size = sample_data.len();
851
852    // zstd requires total sample data >= max_dict_size; repeat samples if needed
853    if total_sample_size > 0 && total_sample_size < max_dict_size {
854        let repeats = (max_dict_size / total_sample_size) + 1;
855        let original_data = sample_data.clone();
856        let original_sizes = sample_sizes.clone();
857        for _ in 0..repeats {
858            sample_data.extend_from_slice(&original_data);
859            sample_sizes.extend_from_slice(&original_sizes);
860        }
861    }
862
863    Ok(Some(
864        zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
865            .capture()?,
866    ))
867}
868
869/// Compress virtual chunk locations in parallel using a pre-trained zstd dictionary.
870///
871/// Returns one entry per chunk: `Some(compressed_bytes)` for virtual chunks,
872/// `None` for inline/ref chunks.
873fn compress_locations(
874    chunks: &[ChunkInfo],
875    dict: &[u8],
876    config: &LocationCompressionConfig,
877) -> Vec<Option<Vec<u8>>> {
878    let compression_level = config.compression_level;
879    let num_threads =
880        std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
881    let slice_size = chunks.len().div_ceil(num_threads);
882
883    std::thread::scope(|s| {
884        let handles: Vec<_> = chunks
885            .chunks(slice_size)
886            .map(|slice| {
887                s.spawn(|| {
888                    let mut comp =
889                        zstd::bulk::Compressor::with_dictionary(compression_level, dict)
890                            .ok();
891                    slice
892                        .iter()
893                        .map(|chunk| match (&chunk.payload, comp.as_mut()) {
894                            (ChunkPayload::Virtual(vref), Some(comp)) => {
895                                comp.compress(vref.location.url().as_bytes()).ok()
896                            }
897                            _ => None,
898                        })
899                        .collect::<Vec<_>>()
900                })
901            })
902            .collect();
903
904        #[expect(clippy::expect_used)]
905        handles
906            .into_iter()
907            .flat_map(|h| {
908                h.join().expect("Cannot join threads compressing virtual chunk locations")
909            })
910            .collect()
911    })
912}
913
914fn mk_chunk_ref<'bldr>(
915    builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
916    chunk: ChunkInfo,
917    precompressed_location: Option<Vec<u8>>,
918) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
919    let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
920    match chunk.payload {
921        ChunkPayload::Inline(bytes) => {
922            let bytes = builder.create_vector(bytes.as_ref());
923            let args = generated::ChunkRefArgs {
924                inline: Some(bytes),
925                index,
926                ..Default::default()
927            };
928            generated::ChunkRef::create(builder, &args)
929        }
930        ChunkPayload::Virtual(virtual_chunk_ref) => {
931            let location_str = virtual_chunk_ref.location.0.as_str();
932            let (location, compressed_location) =
933                if let Some(compressed) = precompressed_location {
934                    (None, Some(builder.create_vector(&compressed)))
935                } else {
936                    (Some(builder.create_string(location_str)), None)
937                };
938            let args = generated::ChunkRefArgs {
939                index,
940                location,
941                compressed_location,
942                offset: virtual_chunk_ref.offset,
943                length: virtual_chunk_ref.length,
944                checksum_etag: match &virtual_chunk_ref.checksum {
945                    Some(cs) => match cs {
946                        Checksum::LastModified(_) => None,
947                        Checksum::ETag(etag) => {
948                            Some(builder.create_string(etag.0.as_str()))
949                        }
950                    },
951                    None => None,
952                },
953                checksum_last_modified: match &virtual_chunk_ref.checksum {
954                    Some(cs) => match cs {
955                        Checksum::LastModified(seconds) => seconds.0,
956                        Checksum::ETag(_) => 0,
957                    },
958                    None => 0,
959                },
960                ..Default::default()
961            };
962            generated::ChunkRef::create(builder, &args)
963        }
964        ChunkPayload::Ref(chunk_ref) => {
965            let id = generated::ObjectId12::new(&chunk_ref.id.0);
966            let args = generated::ChunkRefArgs {
967                index,
968                offset: chunk_ref.offset,
969                length: chunk_ref.length,
970                chunk_id: Some(&id),
971                ..Default::default()
972            };
973            generated::ChunkRef::create(builder, &args)
974        }
975    }
976}
977
978static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
979    max_depth: 64,
980    max_tables: 500_000_000,
981    max_apparent_size: 1 << 31, // taken from the default
982    ignore_missing_null_terminator: true,
983};
984
985#[cfg(test)]
986#[expect(unused_qualifications)] // proptest macros generate fully qualified paths
987mod tests {
988    use super::*;
989    use crate::roundtrip_serialization_tests;
990    use crate::strategies::{
991        ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
992        manifest_splits, shapes_and_dims,
993    };
994    use icechunk_macros::{self, tokio_test};
995    use itertools::{all, multizip};
996    use proptest::collection::vec;
997    use proptest::prelude::*;
998    use std::error::Error;
999    use test_strategy::proptest as alt_proptest;
1000
1001    roundtrip_serialization_tests!(
1002        serialize_and_deserialize_manifest_ref - manifest_ref,
1003        serialize_and_deserialize_manifest_splits - manifest_splits
1004    );
1005
1006    #[alt_proptest]
1007    fn test_property_extents_set_ops_same(
1008        #[strategy(manifest_extents(4))] e: ManifestExtents,
1009    ) {
1010        prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1011        prop_assert_eq!(e.union(&e), e.clone());
1012        prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1013    }
1014
1015    #[alt_proptest]
1016    fn test_property_extents_set_ops(
1017        #[strategy(manifest_extents(4))] e1: ManifestExtents,
1018        #[strategy(manifest_extents(4))] e2: ManifestExtents,
1019    ) {
1020        let union = e1.union(&e2);
1021        let intersection = e1.intersection(&e2);
1022
1023        prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1024        prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1025        prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1026        prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1027
1028        // order is important for the next 2
1029        prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1030        prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1031
1032        if intersection.is_some() {
1033            let int = intersection.unwrap();
1034            let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1035            prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1036            prop_assert_eq!(int.overlap_with(&e2), expected);
1037        } else {
1038            prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1039            prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1040        }
1041    }
1042
1043    #[alt_proptest]
1044    fn test_property_extents_widths(
1045        #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1046        #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1047        #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1048    ) {
1049        let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1050        let extent2 = ManifestExtents::from_ranges_iter(
1051            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1052                |(extent, dleft, dright)| {
1053                    ((extent.start as i32 + dleft) as u32)
1054                        ..((extent.end as i32 + dright) as u32)
1055                },
1056            ),
1057        );
1058
1059        if all(delta_left.iter(), |elem| elem == &0i32)
1060            && all(delta_right.iter(), |elem| elem == &0i32)
1061        {
1062            prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1063        }
1064
1065        let extent2 = ManifestExtents::from_ranges_iter(
1066            multizip((
1067                extent1.iter(),
1068                widths.iter(),
1069                delta_left.iter(),
1070                delta_right.iter(),
1071            ))
1072            .map(|(extent, width, dleft, dright)| {
1073                let (low, high) = (dleft.min(dright), dleft.max(dright));
1074                ((extent.start as i32 + width + low) as u32)
1075                    ..((extent.end as i32 + width + high) as u32)
1076            }),
1077        );
1078
1079        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1080
1081        let extent2 = ManifestExtents::from_ranges_iter(
1082            multizip((
1083                extent1.iter(),
1084                widths.iter(),
1085                delta_left.iter(),
1086                delta_right.iter(),
1087            ))
1088            .map(|(extent, width, dleft, dright)| {
1089                let (low, high) = (dleft.min(dright), dleft.max(dright));
1090                ((extent.start as i32 - width - high).max(0i32) as u32)
1091                    ..((extent.end as i32 - width - low) as u32)
1092            }),
1093        );
1094
1095        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1096
1097        let extent2 = ManifestExtents::from_ranges_iter(
1098            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1099                |(extent, dleft, dright)| {
1100                    ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1101                        ..((extent.end as i32 + dright + 1) as u32)
1102                },
1103            ),
1104        );
1105        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1106    }
1107
1108    #[icechunk_macros::test]
1109    fn test_overlaps() -> Result<(), Box<dyn Error>> {
1110        let e1 = ManifestExtents::new(
1111            vec![0u32, 1, 2].as_slice(),
1112            vec![2u32, 4, 6].as_slice(),
1113        );
1114
1115        let e2 = ManifestExtents::new(
1116            vec![10u32, 1, 2].as_slice(),
1117            vec![12u32, 4, 6].as_slice(),
1118        );
1119
1120        let union = ManifestExtents::new(
1121            vec![0u32, 1, 2].as_slice(),
1122            vec![12u32, 4, 6].as_slice(),
1123        );
1124
1125        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1126        assert_eq!(e1.intersection(&e2), None);
1127        assert_eq!(e1.union(&e2), union);
1128
1129        let e1 = ManifestExtents::new(
1130            vec![0u32, 1, 2].as_slice(),
1131            vec![2u32, 4, 6].as_slice(),
1132        );
1133        let e2 = ManifestExtents::new(
1134            vec![2u32, 1, 2].as_slice(),
1135            vec![42u32, 4, 6].as_slice(),
1136        );
1137        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1138        assert_eq!(e1.overlap_with(&e2), Overlap::None);
1139
1140        // asymmetric case
1141        let e1 = ManifestExtents::new(
1142            vec![0u32, 1, 2].as_slice(),
1143            vec![3u32, 4, 6].as_slice(),
1144        );
1145        let e2 = ManifestExtents::new(
1146            vec![2u32, 1, 2].as_slice(),
1147            vec![3u32, 4, 6].as_slice(),
1148        );
1149        let union = ManifestExtents::new(
1150            vec![0u32, 1, 2].as_slice(),
1151            vec![3u32, 4, 6].as_slice(),
1152        );
1153        let intersection = ManifestExtents::new(
1154            vec![2u32, 1, 2].as_slice(),
1155            vec![3u32, 4, 6].as_slice(),
1156        );
1157        assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1158        assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1159        assert_eq!(e1.union(&e2), union.clone());
1160        assert_eq!(e2.union(&e1), union.clone());
1161        assert_eq!(e1.intersection(&e2), Some(intersection));
1162
1163        // empty set
1164        let e1 = ManifestExtents::new(
1165            vec![0u32, 1, 2].as_slice(),
1166            vec![3u32, 4, 6].as_slice(),
1167        );
1168        let e2 = ManifestExtents::new(
1169            vec![2u32, 1, 2].as_slice(),
1170            vec![2u32, 4, 6].as_slice(),
1171        );
1172        assert_eq!(e1.intersection(&e2), None);
1173
1174        // this should create non-overlapping extents
1175        let splits = ManifestSplits::from_edges(vec![
1176            vec![0, 10, 20],
1177            vec![0, 1, 2],
1178            vec![0, 21, 22],
1179        ]);
1180        for vec in splits.iter().combinations(2) {
1181            assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1182            assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1183        }
1184
1185        Ok(())
1186    }
1187
1188    #[alt_proptest]
1189    fn test_manifest_split_from_edges(
1190        #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1191    ) {
1192        // Note: using the shape, chunks strategy to generate chunk_shape, split_shape
1193        let ShapeDim { shape, .. } = shape_dim;
1194
1195        let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1196        let split_shape: Vec<u64> = shape
1197            .iter()
1198            .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1199            .collect();
1200
1201        let ndim = shape.len();
1202        let edges: Vec<Vec<u32>> = (0usize..ndim)
1203            .map(|axis| {
1204                uniform_manifest_split_edges(
1205                    num_chunks[axis],
1206                    &(split_shape[axis] as u32),
1207                )
1208            })
1209            .collect();
1210
1211        let splits = ManifestSplits::from_edges(edges.into_iter());
1212        for edge in splits.iter() {
1213            // must be ndim ranges
1214            prop_assert_eq!(edge.len(), ndim);
1215            for range in edge.iter() {
1216                prop_assert!(range.end > range.start);
1217            }
1218        }
1219
1220        // when using from_edges, extents must not exactly overlap
1221        for edges in splits.iter().combinations(2) {
1222            let is_equal =
1223                zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1224                    (range1.start == range2.start) && (range1.end == range2.end)
1225                });
1226            prop_assert!(!is_equal);
1227        }
1228    }
1229
1230    const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1231        min_num_chunks: 10,
1232        dictionary_max_training_samples: 500,
1233        dictionary_max_size_bytes: 16 * 1024,
1234        compression_level: 3,
1235    };
1236
1237    fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1238        let node = NodeId::random();
1239        (0..n)
1240            .map(|i| ChunkInfo {
1241                node: node.clone(),
1242                coord: ChunkIndices(vec![i as u32]),
1243                payload: ChunkPayload::Virtual(VirtualChunkRef {
1244                    location: VirtualChunkLocation::from_url(&format!(
1245                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1246                    ))
1247                    .unwrap(),
1248                    offset: i as u64 * 1024,
1249                    length: 1024,
1250                    checksum: None,
1251                }),
1252            })
1253            .collect()
1254    }
1255
1256    #[tokio_test]
1257    async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1258        // >= threshold virtual chunks with compress=true should round-trip
1259        let chunks = make_virtual_chunks(50);
1260        let manifest = Manifest::from_iter(
1261            &ManifestId::random(),
1262            chunks.clone(),
1263            Some(&COMPRESS_CONFIG),
1264        )
1265        .await?
1266        .unwrap();
1267
1268        let root = manifest.root();
1269        assert!(root.location_dictionary().is_some());
1270        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1271        for am in root.arrays().iter() {
1272            for r in am.refs().iter() {
1273                assert!(r.compressed_location().is_some());
1274                assert!(r.location().is_none());
1275            }
1276        }
1277
1278        for chunk in &chunks {
1279            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1280            assert_eq!(payload, chunk.payload);
1281        }
1282        Ok(())
1283    }
1284
1285    #[tokio_test]
1286    async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1287        // Below threshold: compress=true but few chunks → no compression, readback works
1288        let chunks = make_virtual_chunks(5);
1289        let manifest = Manifest::from_iter(
1290            &ManifestId::random(),
1291            chunks.clone(),
1292            Some(&COMPRESS_CONFIG),
1293        )
1294        .await?
1295        .unwrap();
1296
1297        let root = manifest.root();
1298        assert!(root.location_dictionary().is_none());
1299        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1300        for am in root.arrays().iter() {
1301            for r in am.refs().iter() {
1302                assert!(r.compressed_location().is_none());
1303                assert!(r.location().is_some());
1304            }
1305        }
1306
1307        for chunk in &chunks {
1308            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1309            assert_eq!(payload, chunk.payload);
1310        }
1311        Ok(())
1312    }
1313
1314    #[tokio_test]
1315    async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1316        // Mix of virtual, inline, and ref payloads with compression
1317        let node = NodeId::random();
1318        let mut chunks: Vec<ChunkInfo> = (0..30)
1319            .map(|i| ChunkInfo {
1320                node: node.clone(),
1321                coord: ChunkIndices(vec![i]),
1322                payload: ChunkPayload::Virtual(VirtualChunkRef {
1323                    location: VirtualChunkLocation::from_url(&format!(
1324                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1325                    ))
1326                    .unwrap(),
1327                    offset: i as u64 * 1024,
1328                    length: 1024,
1329                    checksum: None,
1330                }),
1331            })
1332            .collect();
1333        chunks.push(ChunkInfo {
1334            node: node.clone(),
1335            coord: ChunkIndices(vec![100]),
1336            payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1337        });
1338        chunks.push(ChunkInfo {
1339            node: node.clone(),
1340            coord: ChunkIndices(vec![101]),
1341            payload: ChunkPayload::Ref(ChunkRef {
1342                id: ChunkId::random(),
1343                offset: 0,
1344                length: 512,
1345            }),
1346        });
1347
1348        let manifest = Manifest::from_iter(
1349            &ManifestId::random(),
1350            chunks.clone(),
1351            Some(&COMPRESS_CONFIG),
1352        )
1353        .await?
1354        .unwrap();
1355
1356        for chunk in &chunks {
1357            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1358            assert_eq!(payload, chunk.payload);
1359        }
1360        Ok(())
1361    }
1362
1363    #[tokio_test]
1364    async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1365        // bytes() → from_buffer() round-trip
1366        let chunks = make_virtual_chunks(50);
1367        let manifest = Manifest::from_iter(
1368            &ManifestId::random(),
1369            chunks.clone(),
1370            Some(&COMPRESS_CONFIG),
1371        )
1372        .await?
1373        .unwrap();
1374        let bytes = manifest.bytes().to_vec();
1375        let manifest2 = Manifest::from_buffer(bytes)?;
1376
1377        for chunk in &chunks {
1378            let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1379            assert_eq!(payload, chunk.payload);
1380        }
1381        Ok(())
1382    }
1383
1384    #[tokio_test]
1385    async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1386        // Iterator-based access with compression
1387        let chunks = make_virtual_chunks(50);
1388        let node = chunks[0].node.clone();
1389        let manifest = Arc::new(
1390            Manifest::from_iter(
1391                &ManifestId::random(),
1392                chunks.clone(),
1393                Some(&COMPRESS_CONFIG),
1394            )
1395            .await?
1396            .unwrap(),
1397        );
1398
1399        let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1400        assert_eq!(iter_results.len(), 50);
1401        for (i, result) in iter_results.into_iter().enumerate() {
1402            let (coord, payload) = result?;
1403            assert_eq!(coord, chunks[i].coord);
1404            assert_eq!(payload, chunks[i].payload);
1405        }
1406        Ok(())
1407    }
1408
1409    #[tokio_test]
1410    async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1411        // compress=false → locations in `location` field, no compressed_location, no dictionary
1412        let chunks = make_virtual_chunks(50);
1413        let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1414            .await?
1415            .unwrap();
1416
1417        let root = manifest.root();
1418        assert!(root.location_dictionary().is_none());
1419        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1420
1421        // Verify location field is populated (not compressed_location)
1422        for am in root.arrays().iter() {
1423            for r in am.refs().iter() {
1424                if r.chunk_id().is_none() && r.inline().is_none() {
1425                    assert!(r.location().is_some(), "location should be set");
1426                    assert!(
1427                        r.compressed_location().is_none(),
1428                        "compressed_location should be None"
1429                    );
1430                }
1431            }
1432        }
1433
1434        // Readback still works
1435        for chunk in &chunks {
1436            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1437            assert_eq!(payload, chunk.payload);
1438        }
1439        Ok(())
1440    }
1441
1442    #[tokio_test]
1443    async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1444        // compress=true with enough virtual chunks → compressed_location, dictionary present
1445        let chunks = make_virtual_chunks(50);
1446        let manifest = Manifest::from_iter(
1447            &ManifestId::random(),
1448            chunks.clone(),
1449            Some(&COMPRESS_CONFIG),
1450        )
1451        .await?
1452        .unwrap();
1453
1454        let root = manifest.root();
1455        assert!(root.location_dictionary().is_some());
1456        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1457
1458        // Verify compressed_location is populated and location is None
1459        for am in root.arrays().iter() {
1460            for r in am.refs().iter() {
1461                if r.chunk_id().is_none() && r.inline().is_none() {
1462                    assert!(
1463                        r.compressed_location().is_some(),
1464                        "compressed_location should be set"
1465                    );
1466                    assert!(
1467                        r.location().is_none(),
1468                        "location should be None for compressed chunks"
1469                    );
1470                }
1471            }
1472        }
1473
1474        // Readback still works
1475        for chunk in &chunks {
1476            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1477            assert_eq!(payload, chunk.payload);
1478        }
1479        Ok(())
1480    }
1481
1482    #[tokio_test]
1483    async fn test_compression_skipped_when_locations_too_short()
1484    -> Result<(), Box<dyn Error>> {
1485        // When most virtual chunk locations are < 8 bytes, dictionary training
1486        // is skipped and the manifest falls back to uncompressed storage.
1487        // With longer locations (>= 8 bytes), compression kicks in.
1488        let config = LocationCompressionConfig {
1489            min_num_chunks: 2,
1490            dictionary_max_training_samples: 500,
1491            dictionary_max_size_bytes: 256,
1492            compression_level: 3,
1493        };
1494
1495        let node = NodeId::random();
1496        // "s3://a/" is 7 bytes, below the 8-byte threshold
1497        let short_chunks: Vec<ChunkInfo> = (0..2)
1498            .map(|i| ChunkInfo {
1499                node: node.clone(),
1500                coord: ChunkIndices(vec![i as u32]),
1501                payload: ChunkPayload::Virtual(VirtualChunkRef {
1502                    location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1503                    offset: i as u64 * 1024,
1504                    length: 1024,
1505                    checksum: None,
1506                }),
1507            })
1508            .collect();
1509
1510        let manifest = Manifest::from_iter(
1511            &ManifestId::random(),
1512            short_chunks.clone(),
1513            Some(&config),
1514        )
1515        .await?
1516        .unwrap();
1517
1518        let root = manifest.root();
1519        assert!(root.location_dictionary().is_none());
1520        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1521        for am in root.arrays().iter() {
1522            for r in am.refs().iter() {
1523                assert!(r.compressed_location().is_none());
1524                assert!(r.location().is_some());
1525            }
1526        }
1527
1528        for chunk in &short_chunks {
1529            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1530            assert_eq!(payload, chunk.payload);
1531        }
1532
1533        // "s3://abc/" is 9 bytes, above the 8-byte threshold — compression should apply
1534        let long_chunks: Vec<ChunkInfo> = (0..2)
1535            .map(|i| ChunkInfo {
1536                node: node.clone(),
1537                coord: ChunkIndices(vec![i as u32]),
1538                payload: ChunkPayload::Virtual(VirtualChunkRef {
1539                    location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1540                    offset: i as u64 * 1024,
1541                    length: 1024,
1542                    checksum: None,
1543                }),
1544            })
1545            .collect();
1546
1547        let manifest = Manifest::from_iter(
1548            &ManifestId::random(),
1549            long_chunks.clone(),
1550            Some(&config),
1551        )
1552        .await?
1553        .unwrap();
1554
1555        let root = manifest.root();
1556        assert!(root.location_dictionary().is_some());
1557        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1558        for am in root.arrays().iter() {
1559            for r in am.refs().iter() {
1560                assert!(r.compressed_location().is_some());
1561                assert!(r.location().is_none());
1562            }
1563        }
1564
1565        for chunk in &long_chunks {
1566            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1567            assert_eq!(payload, chunk.payload);
1568        }
1569        Ok(())
1570    }
1571
1572    #[tokio_test]
1573    async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1574        // 100 virtual chunks with highly redundant, large location strings.
1575        // Compression should produce a significantly smaller manifest.
1576        let node = NodeId::random();
1577        let chunks: Vec<ChunkInfo> = (0..100)
1578            .map(|i| ChunkInfo {
1579                node: node.clone(),
1580                coord: ChunkIndices(vec![i as u32]),
1581                payload: ChunkPayload::Virtual(VirtualChunkRef {
1582                    location: VirtualChunkLocation::from_url(&format!(
1583                        "s3://my-very-long-bucket-name/some/deeply/nested/path/to/dataset/v1.2.3/year=2024/month=01/day=15/chunk_{i:06}.parquet"
1584                    ))
1585                    .unwrap(),
1586                    offset: i as u64 * 4096,
1587                    length: 4096,
1588                    checksum: None,
1589                }),
1590            })
1591            .collect();
1592
1593        // Build without compression
1594        let manifest_uncompressed =
1595            Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1596                .await?
1597                .unwrap();
1598        let size_uncompressed = manifest_uncompressed.bytes().len();
1599
1600        assert!(!manifest_uncompressed.uses_location_compression());
1601        assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1602
1603        // Build with compression
1604        let manifest_compressed = Manifest::from_iter(
1605            &ManifestId::random(),
1606            chunks.clone(),
1607            Some(&COMPRESS_CONFIG),
1608        )
1609        .await?
1610        .unwrap();
1611        let size_compressed = manifest_compressed.bytes().len();
1612
1613        assert!(manifest_compressed.uses_location_compression());
1614        assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1615        assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1616
1617        // Each compressed_location should be much shorter than the raw URL (118 bytes)
1618        let root = manifest_compressed.root();
1619        for am in root.arrays().iter() {
1620            for r in am.refs().iter() {
1621                let compressed = r.compressed_location().unwrap();
1622                assert!(
1623                    compressed.len() < 40,
1624                    "compressed_location ({} bytes) should be under 40 bytes \
1625                     (raw location is 118 bytes)",
1626                    compressed.len(),
1627                );
1628            }
1629        }
1630
1631        // Compressed manifest should be meaningfully smaller
1632        assert!(
1633            size_compressed < size_uncompressed,
1634            "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1635        );
1636
1637        let saving_pct =
1638            (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1639        assert!(
1640            saving_pct > 15.0,
1641            "expected at least 15% size reduction with redundant locations, \
1642             got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1643        );
1644
1645        // Both manifests must round-trip correctly
1646        for chunk in &chunks {
1647            let p1 = manifest_uncompressed
1648                .get_chunk_payload(&chunk.node, &chunk.coord)
1649                .unwrap();
1650            let p2 =
1651                manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1652            assert_eq!(p1, chunk.payload);
1653            assert_eq!(p2, chunk.payload);
1654        }
1655
1656        Ok(())
1657    }
1658
1659    #[tokio_test]
1660    async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1661        // chunk_payloads() works with compressed manifests
1662        let chunks = make_virtual_chunks(50);
1663        let manifest = Manifest::from_iter(
1664            &ManifestId::random(),
1665            chunks.clone(),
1666            Some(&COMPRESS_CONFIG),
1667        )
1668        .await?
1669        .unwrap();
1670
1671        let payloads: Vec<_> =
1672            manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1673        assert_eq!(payloads.len(), 50);
1674        for (i, payload) in payloads.iter().enumerate() {
1675            assert_eq!(payload, &chunks[i].payload);
1676        }
1677        Ok(())
1678    }
1679}