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(
244        "object store backend cannot address key {0:?}: it contains empty (//), '.' or '..' path segments. If you need support for keys like this, please reach out to the Icechunk team by opening an issue describing your situation at https://github.com/earth-mover/icechunk/issues"
245    )]
246    UnsupportedObjectKeyForBackend(String),
247    #[error("error fetching virtual reference")]
248    FetchError(#[source] Box<dyn std::error::Error + Send + Sync>),
249    #[error("the checksum of the object owning the virtual chunk has changed ({0})")]
250    ObjectModified(String),
251    #[error(
252        "error retrieving virtual chunk, not enough data. Expected: ({expected}), available ({available})"
253    )]
254    InvalidObjectSize { expected: u64, available: u64 },
255    #[error("azure store configuration must include an account")]
256    AzureConfigurationMustIncludeAccount,
257    #[error("decoding virtual chunk url")]
258    Decoding(#[from] FromUtf8Error),
259    #[error(
260        "no virtual chunk container named '{0}' found, check the repository configuration"
261    )]
262    NoContainerForName(String),
263    #[error("unknown error")]
264    OtherError(#[from] Box<dyn std::error::Error + Send + Sync>),
265}
266
267fn format_unauthorized_vcc(url_prefix: &str, name: &Option<String>) -> String {
268    let container = match name {
269        Some(n) => format!(" (container: {n})"),
270        None => String::new(),
271    };
272    format!(
273        "a virtual chunk in this repository resolves to the url prefix {url_prefix}{container}, \
274         to be able to fetch the chunk you need to authorize the virtual chunk container \
275         when you open/create the repository, see https://icechunk.io/en/stable/virtual/"
276    )
277}
278
279pub type VirtualReferenceError = ICError<VirtualReferenceErrorKind>;
280
281pub const VCC_RELATIVE_URL_SCHEME: &str = "vcc://";
282
283#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
284pub struct VirtualChunkLocation(String);
285
286impl VirtualChunkLocation {
287    pub fn url(&self) -> &str {
288        self.0.as_str()
289    }
290
291    /// Wrap a pre-validated location string without re-parsing.
292    fn from_trusted(s: String) -> Self {
293        VirtualChunkLocation(s)
294    }
295
296    /// Returns true if this is a relative `vcc://` location.
297    pub fn is_relative(&self) -> bool {
298        self.0.starts_with(VCC_RELATIVE_URL_SCHEME)
299    }
300
301    /// If this is a `vcc://name/path` location, returns `(name, relative_path)`.
302    pub fn parse_vcc(&self) -> Option<(&str, &str)> {
303        let rest = self.0.strip_prefix(VCC_RELATIVE_URL_SCHEME)?;
304        let slash = rest.find('/')?;
305        Some((&rest[..slash], &rest[slash + 1..]))
306    }
307
308    /// Creates a relative location from a VCC name and a path relative to its prefix.
309    pub fn from_vcc_path(
310        container_name: &str,
311        relative_path: &str,
312    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
313        if container_name.is_empty() || container_name.contains('/') {
314            return Err(VirtualReferenceError::capture(
315                VirtualReferenceErrorKind::NoContainerForName(container_name.to_string()),
316            ));
317        }
318        let mut result = String::with_capacity(
319            VCC_RELATIVE_URL_SCHEME.len()
320                + container_name.len()
321                + 1
322                + relative_path.len(),
323        );
324        result.push_str(VCC_RELATIVE_URL_SCHEME);
325        result.push_str(container_name);
326        for segment in relative_path.split('/').filter(|s| !s.is_empty()) {
327            result.push('/');
328            result.push_str(segment);
329        }
330        Ok(VirtualChunkLocation(result))
331    }
332
333    /// Parse a virtual chunk location from a URL string.
334    ///
335    /// `path` is a URL: an absolute location (`s3://`, `gs://`, `file://`,
336    /// `https://`, …) or a `vcc://name/relative` reference into a named container.
337    ///
338    /// For object-store schemes the object key is the URL's path and is kept
339    /// **verbatim** — repeated slashes (`//`) and `.`/`..` segments are preserved,
340    /// not normalized, so the reference always addresses the exact object. Because
341    /// the input is a URL, any character that is reserved in a URL and is meant to
342    /// be part of the key must be percent-encoded. In particular `?` and `#` start
343    /// the URL query and fragment, so a key that literally contains them (or a
344    /// literal `%`) must be encoded: the key `a?b#c` is the URL
345    /// `s3://bucket/a%3Fb%23c`, and a literal `%` is written `%25`. A query or
346    /// fragment, if present, is preserved in the stored location but is not part
347    /// of the object key.
348    ///
349    /// `file://` is the exception: its path is WHATWG-normalized (resolving
350    /// `.`/`..`), which is required so a reference cannot escape its container.
351    pub fn from_url(path: &str) -> Result<VirtualChunkLocation, VirtualReferenceError> {
352        // vcc:// is a valid URL scheme, so from_absolute_path handles absolute
353        // (s3://, gcs://, file://, …) and relative (vcc://) URLs alike.
354        Self::from_absolute_path(path)
355    }
356
357    fn from_absolute_path(
358        path: &str,
359    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
360        match path.split_once("://") {
361            // Object-store and `vcc://` schemes use the verbatim path as location
362            Some((scheme, after_scheme)) if !scheme.eq_ignore_ascii_case("file") => {
363                Self::from_verbatim_url(path, scheme, after_scheme)
364            }
365            // `file://` is a security boundary: there `..` is a path traversal
366            // and must be normalized away.
367            // Anything without a clean `scheme://` (like `mailto:foo`,
368            // single-slash forms like `s3:/x`, or malformed input) is rare and
369            // goes through the parser too for normalization and good errors
370            _ => Self::from_normalized_url(path),
371        }
372    }
373
374    /// Cheap validation for virtual chunk locations in object stores or vcc schemes
375    fn from_verbatim_url(
376        path: &str,
377        scheme: &str,
378        after_scheme: &str,
379    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
380        // Authority is the substring between "://" and the first '/', '?' or '#'.
381        // It must be non-empty: a bucket/host, or a `vcc://` container name.
382        let authority_len =
383            after_scheme.find(['/', '?', '#']).unwrap_or(after_scheme.len());
384        if authority_len == 0 {
385            let kind = if scheme.eq_ignore_ascii_case("vcc") {
386                VirtualReferenceErrorKind::NoContainerForName(path.to_string())
387            } else {
388                VirtualReferenceErrorKind::CannotParseBucketName(path.to_string())
389            };
390            return Err(VirtualReferenceError::capture(kind));
391        }
392
393        // We normalize scheme that is not part of the actual object store key
394        let mut location = path.to_string();
395        location[..scheme.len()].make_ascii_lowercase();
396        Ok(VirtualChunkLocation(location))
397    }
398
399    /// Normalize the full url, including the filesystem "key"
400    fn from_normalized_url(
401        path: &str,
402    ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
403        let url = url::Url::parse(path)
404            .map_err(|e| VirtualReferenceErrorKind::CannotParseUrl {
405                cause: e,
406                url: path.to_string(),
407            })
408            .capture()?;
409        let scheme = url.scheme();
410        // cannot-be-a-base URLs (e.g. `mailto:foo`) have no path segments and
411        // cannot name an object in a store.
412        url.path_segments()
413            .ok_or_else(|| VirtualReferenceErrorKind::NoPathSegments(path.into()))
414            .capture()?;
415
416        if url.host_str().is_none() {
417            match scheme {
418                "file" => {}
419                "vcc" => {
420                    return Err(VirtualReferenceError::capture(
421                        VirtualReferenceErrorKind::NoContainerForName(path.into()),
422                    ));
423                }
424                _ => {
425                    return Err(VirtualReferenceError::capture(
426                        VirtualReferenceErrorKind::CannotParseBucketName(path.into()),
427                    ));
428                }
429            }
430        }
431
432        Ok(VirtualChunkLocation(url.as_str().to_string()))
433    }
434}
435
436#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
437pub struct SecondsSinceEpoch(pub u32);
438
439#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
440pub enum Checksum {
441    LastModified(SecondsSinceEpoch),
442    ETag(ETag),
443}
444
445#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
446pub struct VirtualChunkRef {
447    pub location: VirtualChunkLocation,
448    pub offset: ChunkOffset,
449    pub length: ChunkLength,
450    pub checksum: Option<Checksum>,
451}
452
453#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
454pub struct ChunkRef {
455    pub id: ChunkId,
456    pub offset: ChunkOffset,
457    pub length: ChunkLength,
458}
459
460#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
461#[non_exhaustive]
462pub enum ChunkPayload {
463    Inline(Bytes),
464    Virtual(VirtualChunkRef),
465    Ref(ChunkRef),
466}
467
468#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
469pub struct ChunkInfo {
470    pub node: NodeId,
471    pub coord: ChunkIndices,
472    pub payload: ChunkPayload,
473}
474
475const COMPRESSION_ALG_NONE: u8 = 0;
476const COMPRESSION_ALG_ZSTD_DICT: u8 = 1;
477// This is the maximum size we support for a virtual chunk url that will be compressed
478const MAX_DECOMPRESSED_LOCATION_SIZE: usize = 1_024;
479
480#[derive(PartialEq)]
481pub struct Manifest {
482    buffer: Vec<u8>,
483}
484
485impl std::fmt::Debug for Manifest {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        f.debug_struct("Manifest")
488            .field("id", &self.id())
489            .field("chunks", &self.len())
490            .finish_non_exhaustive()
491    }
492}
493
494impl Manifest {
495    pub fn id(&self) -> ManifestId {
496        ManifestId::new(self.root().id().0)
497    }
498
499    pub fn bytes(&self) -> &[u8] {
500        self.buffer.as_slice()
501    }
502
503    pub fn from_buffer(buffer: Vec<u8>) -> Result<Manifest, IcechunkFormatError> {
504        let _ = flatbuffers::root_with_opts::<generated::Manifest<'_>>(
505            &ROOT_OPTIONS,
506            buffer.as_slice(),
507        )
508        .capture()?;
509        Ok(Manifest { buffer })
510    }
511
512    /// Create a zstd decompressor from the manifest's location dictionary, if present.
513    fn decompressor(
514        &self,
515    ) -> Result<Option<zstd::bulk::Decompressor<'static>>, IcechunkFormatError> {
516        let root = self.root();
517        if root.compression_algorithm() != COMPRESSION_ALG_ZSTD_DICT {
518            return Ok(None);
519        }
520        match root.location_dictionary() {
521            Some(dict_bytes) => {
522                let decompressor =
523                    zstd::bulk::Decompressor::with_dictionary(dict_bytes.bytes())
524                        .capture()?;
525                Ok(Some(decompressor))
526            }
527            None => Ok(None),
528        }
529    }
530
531    pub fn from_sorted_vec(
532        manifest_id: &ManifestId,
533        sorted_chunks: Vec<ChunkInfo>,
534        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
535    ) -> IcechunkResult<Option<Self>> {
536        let location_compression_dict =
537            train_location_dictionary(&sorted_chunks, virtual_chunks_compression_config)?;
538        // we generate all compressed locations if needed
539        let compressed_locations =
540            match (&location_compression_dict, virtual_chunks_compression_config) {
541                (Some(d), Some(config)) => compress_locations(&sorted_chunks, d, config),
542                _ => vec![None; sorted_chunks.len()],
543            };
544
545        // Sequential FlatBuffer building using pre-compressed locations
546        // TODO: what's a good capacity?
547        let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024);
548
549        let len = sorted_chunks.len();
550        let mut all = sorted_chunks.into_iter().zip(compressed_locations).peekable();
551
552        let mut array_manifests = Vec::with_capacity(1);
553        while let Some(current_node) = all.peek().map(|(chunk, _)| chunk.node.clone()) {
554            // TODO: adjust capacity when multiple arrays have their manifests consolidated in to one.
555            let mut refs = Vec::with_capacity(len);
556            while let Some((chunk, precompressed)) =
557                all.next_if(|(chunk, _)| chunk.node == current_node)
558            {
559                refs.push(mk_chunk_ref(&mut builder, chunk, precompressed));
560            }
561
562            let node_id = Some(generated::ObjectId8::new(&current_node.0));
563            let refs = Some(builder.create_vector(refs.as_slice()));
564            let array_manifest = generated::ArrayManifest::create(
565                &mut builder,
566                &generated::ArrayManifestArgs {
567                    node_id: node_id.as_ref(),
568                    refs,
569                    ..Default::default()
570                },
571            );
572            array_manifests.push(array_manifest);
573        }
574
575        if array_manifests.is_empty() {
576            // empty manifest
577            return Ok(None);
578        }
579
580        let arrays = builder.create_vector(array_manifests.as_slice());
581        let bin_manifest_id = generated::ObjectId12::new(&manifest_id.0);
582
583        let (location_dictionary, compression_algorithm) =
584            if let Some(ref dict) = location_compression_dict {
585                (Some(builder.create_vector(dict.as_slice())), COMPRESSION_ALG_ZSTD_DICT)
586            } else {
587                (None, COMPRESSION_ALG_NONE)
588            };
589
590        let manifest = generated::Manifest::create(
591            &mut builder,
592            &generated::ManifestArgs {
593                id: Some(&bin_manifest_id),
594                arrays: Some(arrays),
595                location_dictionary,
596                compression_algorithm,
597                ..Default::default()
598            },
599        );
600
601        builder.finish(manifest, Some("Ichk"));
602        let (mut buffer, offset) = builder.collapse();
603        buffer.drain(0..offset);
604        buffer.shrink_to_fit();
605        Ok(Some(Manifest { buffer }))
606    }
607
608    pub async fn from_stream<E>(
609        manifest_id: &ManifestId,
610        stream: impl Stream<Item = Result<ChunkInfo, E>>,
611        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
612    ) -> Result<Option<Self>, E>
613    where
614        E: From<IcechunkFormatError>,
615    {
616        let mut all = stream.try_collect::<Vec<_>>().await?;
617        all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord)));
618        Ok(Self::from_sorted_vec(manifest_id, all, virtual_chunks_compression_config)?)
619    }
620
621    /// Used for tests
622    pub async fn from_iter<T: IntoIterator<Item = ChunkInfo>>(
623        manifest_id: &ManifestId,
624        iter: T,
625        virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
626    ) -> IcechunkResult<Option<Self>> {
627        Self::from_stream(
628            manifest_id,
629            futures::stream::iter(iter.into_iter().map(Ok::<_, IcechunkFormatError>)),
630            virtual_chunks_compression_config,
631        )
632        .await
633    }
634
635    pub fn len(&self) -> usize {
636        self.root().arrays().iter().map(|am| am.refs().len()).sum()
637    }
638
639    #[must_use]
640    pub fn is_empty(&self) -> bool {
641        self.len() == 0
642    }
643
644    #[expect(unsafe_code)]
645    fn root(&self) -> generated::Manifest<'_> {
646        // SAFETY: self.buffer was serialized by our own flatbuffers serialization code.
647        // We skip validation for performance; a corrupt buffer here indicates
648        // file corruption or a bad Icechunk implementation, not a caller error.
649        unsafe { flatbuffers::root_unchecked::<generated::Manifest<'_>>(&self.buffer) }
650    }
651
652    pub fn arrays(&self) -> impl Iterator<Item = NodeId> {
653        self.root().arrays().iter().map(|am| NodeId::from(am.node_id().0))
654    }
655
656    pub fn uses_location_compression(&self) -> bool {
657        self.root().compression_algorithm() != COMPRESSION_ALG_NONE
658    }
659
660    pub fn location_dictionary_size(&self) -> Option<usize> {
661        self.root().location_dictionary().map(|d| d.len())
662    }
663
664    pub fn num_compressed_refs(&self) -> usize {
665        self.root()
666            .arrays()
667            .iter()
668            .flat_map(|am| am.refs().iter())
669            .filter(|r| r.compressed_location().is_some())
670            .count()
671    }
672
673    pub fn get_chunk_payload(
674        &self,
675        node: &NodeId,
676        coord: &ChunkIndices,
677    ) -> IcechunkResult<ChunkPayload> {
678        let mut decompressor = self.decompressor()?;
679        let manifest = self.root();
680        let chunk_ref = lookup_node(manifest, node)
681            .and_then(|array_manifest| lookup_ref(array_manifest, coord))
682            .ok_or_else(|| IcechunkFormatErrorKind::ChunkCoordinatesNotFound {
683                coords: coord.clone(),
684            })
685            .capture()?;
686        ref_to_payload(chunk_ref, decompressor.as_mut())
687    }
688
689    pub fn iter(
690        self: Arc<Self>,
691        node: NodeId,
692    ) -> Result<
693        impl Iterator<Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>>,
694        IcechunkFormatError,
695    > {
696        PayloadIterator::new(self, node)
697    }
698
699    pub fn chunk_payloads(
700        &self,
701    ) -> Result<
702        impl Iterator<Item = Result<ChunkPayload, IcechunkFormatError>> + '_,
703        IcechunkFormatError,
704    > {
705        let mut decompressor = self.decompressor()?;
706        let refs: Vec<_> =
707            self.root().arrays().iter().flat_map(|am| am.refs().iter()).collect();
708        Ok(refs.into_iter().map(move |r| ref_to_payload(r, decompressor.as_mut())))
709    }
710}
711
712fn lookup_node<'a>(
713    manifest: generated::Manifest<'a>,
714    node: &NodeId,
715) -> Option<generated::ArrayManifest<'a>> {
716    manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id))
717}
718
719fn lookup_ref<'a>(
720    array_manifest: generated::ArrayManifest<'a>,
721    coord: &ChunkIndices,
722) -> Option<generated::ChunkRef<'a>> {
723    array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| {
724        chunk_ref.index().iter().cmp(coords.iter().copied())
725    })
726}
727
728pub struct PayloadIterator {
729    manifest: Arc<Manifest>,
730    node_id: NodeId,
731    last_ref_index: usize,
732    decompressor: Option<zstd::bulk::Decompressor<'static>>,
733}
734
735impl std::fmt::Debug for PayloadIterator {
736    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
737        f.debug_struct("PayloadIterator")
738            .field("node_id", &self.node_id)
739            .field("last_ref_index", &self.last_ref_index)
740            .finish_non_exhaustive()
741    }
742}
743
744impl PayloadIterator {
745    fn new(
746        manifest: Arc<Manifest>,
747        node_id: NodeId,
748    ) -> Result<Self, IcechunkFormatError> {
749        let decompressor = manifest.decompressor()?;
750        Ok(Self { manifest, node_id, last_ref_index: 0, decompressor })
751    }
752}
753
754impl Iterator for PayloadIterator {
755    type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>;
756
757    fn next(&mut self) -> Option<Self::Item> {
758        let manifest = self.manifest.root();
759        lookup_node(manifest, &self.node_id).and_then(|array_manifest| {
760            let refs = array_manifest.refs();
761            if self.last_ref_index >= refs.len() {
762                return None;
763            }
764
765            let chunk_ref = refs.get(self.last_ref_index);
766            self.last_ref_index += 1;
767            Some(
768                ref_to_payload(chunk_ref, self.decompressor.as_mut())
769                    .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)),
770            )
771        })
772    }
773}
774
775fn ref_to_payload(
776    chunk_ref: generated::ChunkRef<'_>,
777    decompressor: Option<&mut zstd::bulk::Decompressor<'static>>,
778) -> Result<ChunkPayload, IcechunkFormatError> {
779    if let Some(chunk_id) = chunk_ref.chunk_id() {
780        let id = ChunkId::new(chunk_id.0);
781        Ok(ChunkPayload::Ref(ChunkRef {
782            id,
783            offset: chunk_ref.offset(),
784            length: chunk_ref.length(),
785        }))
786    } else if let Some(compressed) = chunk_ref.compressed_location() {
787        let decompressor = decompressor
788            .ok_or(IcechunkFormatErrorKind::MissingLocationCompressionDictionary)
789            .capture()?;
790        let decompressed = decompressor
791            .decompress(compressed.bytes(), MAX_DECOMPRESSED_LOCATION_SIZE)
792            .capture()?;
793        let location_string = String::from_utf8(decompressed)
794            .map_err(|e| {
795                IcechunkFormatErrorKind::IO(std::io::Error::new(
796                    std::io::ErrorKind::InvalidData,
797                    e,
798                ))
799            })
800            .capture()?;
801        let location = VirtualChunkLocation::from_trusted(location_string);
802        Ok(ChunkPayload::Virtual(VirtualChunkRef {
803            location,
804            checksum: checksum(&chunk_ref),
805            offset: chunk_ref.offset(),
806            length: chunk_ref.length(),
807        }))
808    } else if let Some(location) = chunk_ref.location() {
809        let location = VirtualChunkLocation::from_trusted(location.to_string());
810        Ok(ChunkPayload::Virtual(VirtualChunkRef {
811            location,
812            checksum: checksum(&chunk_ref),
813            offset: chunk_ref.offset(),
814            length: chunk_ref.length(),
815        }))
816    } else if let Some(data) = chunk_ref.inline() {
817        Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
818    } else {
819        Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
820            flatbuffers::InvalidFlatbuffer::InconsistentUnion {
821                field: Cow::Borrowed("chunk_id+location+inline"),
822                field_type: Cow::Borrowed("invalid"),
823                error_trace: Default::default(),
824            },
825        ))
826        .capture()
827    }
828}
829
830fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
831    if let Some(etag) = payload.checksum_etag() {
832        Some(Checksum::ETag(ETag(etag.to_string())))
833    } else if payload.checksum_last_modified() > 0 {
834        Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
835    } else {
836        None
837    }
838}
839
840/// Sample virtual chunk URLs and train a zstd dictionary for compressing them.
841///
842/// Uses reservoir sampling (Algorithm R) to collect a uniform random sample in a single
843/// pass without knowing the total virtual chunk count in advance.
844/// See: <https://en.wikipedia.org/wiki/Reservoir_sampling#Simple>:_`Algorithm_R`
845///
846/// Returns `Some(dict_bytes)` if compression is enabled and there are enough virtual
847/// chunks, `None` if compression is disabled or cannot be executed.
848fn train_location_dictionary(
849    chunks: &[ChunkInfo],
850    virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
851) -> IcechunkResult<Option<Vec<u8>>> {
852    let Some(config) = virtual_chunks_compression_config else {
853        return Ok(None);
854    };
855    let max_samples = config.dictionary_max_training_samples as usize;
856    let min_chunks = config.min_num_chunks as usize;
857    let max_dict_size = config.dictionary_max_size_bytes as usize;
858
859    let mut virtual_count: usize = 0;
860    let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
861    let mut rng: SmallRng = rand::make_rng();
862
863    if chunks.len() < min_chunks {
864        return Ok(None);
865    }
866
867    for chunk in chunks {
868        if let ChunkPayload::Virtual(vref) = &chunk.payload {
869            let loc = vref.location.url();
870            if virtual_count < max_samples {
871                // Fill phase: reservoir not yet full
872                reservoir.push(loc);
873            } else {
874                // Replace phase: include new item with decreasing probability
875                let j = rng.random_range(0..=virtual_count);
876                if j < max_samples {
877                    reservoir[j] = loc;
878                }
879            }
880            virtual_count += 1;
881        }
882    }
883
884    if virtual_count < min_chunks {
885        return Ok(None);
886    }
887
888    let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
889
890    // zstd doesn't like it if many samples are too small, in that case we don't compress
891    let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
892    if small_count >= sample_bytes.len() / 2 {
893        tracing::warn!(
894            "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
895            sample_bytes.len()
896        );
897        return Ok(None);
898    }
899
900    let mut sample_data: Vec<u8> =
901        sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
902    let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
903    let total_sample_size = sample_data.len();
904
905    // zstd requires total sample data >= max_dict_size; repeat samples if needed
906    if total_sample_size > 0 && total_sample_size < max_dict_size {
907        let repeats = (max_dict_size / total_sample_size) + 1;
908        let original_data = sample_data.clone();
909        let original_sizes = sample_sizes.clone();
910        for _ in 0..repeats {
911            sample_data.extend_from_slice(&original_data);
912            sample_sizes.extend_from_slice(&original_sizes);
913        }
914    }
915
916    Ok(Some(
917        zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
918            .capture()?,
919    ))
920}
921
922/// Compress virtual chunk locations in parallel using a pre-trained zstd dictionary.
923///
924/// Returns one entry per chunk: `Some(compressed_bytes)` for virtual chunks,
925/// `None` for inline/ref chunks.
926fn compress_locations(
927    chunks: &[ChunkInfo],
928    dict: &[u8],
929    config: &LocationCompressionConfig,
930) -> Vec<Option<Vec<u8>>> {
931    let compression_level = config.compression_level;
932
933    #[cfg(not(target_family = "wasm"))]
934    {
935        let num_threads =
936            std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
937        let slice_size = chunks.len().div_ceil(num_threads);
938
939        std::thread::scope(|s| {
940            let handles: Vec<_> = chunks
941                .chunks(slice_size)
942                .map(|slice| {
943                    s.spawn(|| {
944                        let mut comp = zstd::bulk::Compressor::with_dictionary(
945                            compression_level,
946                            dict,
947                        )
948                        .ok();
949                        slice
950                            .iter()
951                            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
952                                (ChunkPayload::Virtual(vref), Some(comp)) => {
953                                    comp.compress(vref.location.url().as_bytes()).ok()
954                                }
955                                _ => None,
956                            })
957                            .collect::<Vec<_>>()
958                    })
959                })
960                .collect();
961
962            #[expect(clippy::expect_used)]
963            handles
964                .into_iter()
965                .flat_map(|h| {
966                    h.join()
967                        .expect("Cannot join threads compressing virtual chunk locations")
968                })
969                .collect()
970        })
971    }
972
973    #[cfg(target_family = "wasm")]
974    {
975        let mut comp =
976            zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
977        chunks
978            .iter()
979            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
980                (ChunkPayload::Virtual(vref), Some(comp)) => {
981                    comp.compress(vref.location.url().as_bytes()).ok()
982                }
983                _ => None,
984            })
985            .collect()
986    }
987}
988
989fn mk_chunk_ref<'bldr>(
990    builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
991    chunk: ChunkInfo,
992    precompressed_location: Option<Vec<u8>>,
993) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
994    let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
995    match chunk.payload {
996        ChunkPayload::Inline(bytes) => {
997            let bytes = builder.create_vector(bytes.as_ref());
998            let args = generated::ChunkRefArgs {
999                inline: Some(bytes),
1000                index,
1001                ..Default::default()
1002            };
1003            generated::ChunkRef::create(builder, &args)
1004        }
1005        ChunkPayload::Virtual(virtual_chunk_ref) => {
1006            let location_str = virtual_chunk_ref.location.0.as_str();
1007            let (location, compressed_location) =
1008                if let Some(compressed) = precompressed_location {
1009                    (None, Some(builder.create_vector(&compressed)))
1010                } else {
1011                    (Some(builder.create_string(location_str)), None)
1012                };
1013            let args = generated::ChunkRefArgs {
1014                index,
1015                location,
1016                compressed_location,
1017                offset: virtual_chunk_ref.offset,
1018                length: virtual_chunk_ref.length,
1019                checksum_etag: match &virtual_chunk_ref.checksum {
1020                    Some(cs) => match cs {
1021                        Checksum::LastModified(_) => None,
1022                        Checksum::ETag(etag) => {
1023                            Some(builder.create_string(etag.0.as_str()))
1024                        }
1025                    },
1026                    None => None,
1027                },
1028                checksum_last_modified: match &virtual_chunk_ref.checksum {
1029                    Some(cs) => match cs {
1030                        Checksum::LastModified(seconds) => seconds.0,
1031                        Checksum::ETag(_) => 0,
1032                    },
1033                    None => 0,
1034                },
1035                ..Default::default()
1036            };
1037            generated::ChunkRef::create(builder, &args)
1038        }
1039        ChunkPayload::Ref(chunk_ref) => {
1040            let id = generated::ObjectId12::new(&chunk_ref.id.0);
1041            let args = generated::ChunkRefArgs {
1042                index,
1043                offset: chunk_ref.offset,
1044                length: chunk_ref.length,
1045                chunk_id: Some(&id),
1046                ..Default::default()
1047            };
1048            generated::ChunkRef::create(builder, &args)
1049        }
1050    }
1051}
1052
1053static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1054    max_depth: 64,
1055    max_tables: 500_000_000,
1056    max_apparent_size: 1 << 31, // taken from the default
1057    ignore_missing_null_terminator: true,
1058};
1059
1060#[cfg(test)]
1061#[expect(unused_qualifications)] // proptest macros generate fully qualified paths
1062mod tests {
1063    use super::*;
1064    use crate::roundtrip_serialization_tests;
1065    use crate::strategies::{
1066        ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1067        manifest_splits, shapes_and_dims,
1068    };
1069    use icechunk_macros::{self, tokio_test};
1070    use itertools::{all, multizip};
1071    use proptest::collection::vec;
1072    use proptest::prelude::*;
1073    use std::error::Error;
1074    use test_strategy::proptest as alt_proptest;
1075
1076    roundtrip_serialization_tests!(
1077        serialize_and_deserialize_manifest_ref - manifest_ref,
1078        serialize_and_deserialize_manifest_splits - manifest_splits
1079    );
1080
1081    #[alt_proptest]
1082    fn test_property_extents_set_ops_same(
1083        #[strategy(manifest_extents(4))] e: ManifestExtents,
1084    ) {
1085        prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1086        prop_assert_eq!(e.union(&e), e.clone());
1087        prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1088    }
1089
1090    #[alt_proptest]
1091    fn test_property_extents_set_ops(
1092        #[strategy(manifest_extents(4))] e1: ManifestExtents,
1093        #[strategy(manifest_extents(4))] e2: ManifestExtents,
1094    ) {
1095        let union = e1.union(&e2);
1096        let intersection = e1.intersection(&e2);
1097
1098        prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1099        prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1100        prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1101        prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1102
1103        // order is important for the next 2
1104        prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1105        prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1106
1107        if intersection.is_some() {
1108            let int = intersection.unwrap();
1109            let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1110            prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1111            prop_assert_eq!(int.overlap_with(&e2), expected);
1112        } else {
1113            prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1114            prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1115        }
1116    }
1117
1118    #[alt_proptest]
1119    fn test_property_extents_widths(
1120        #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1121        #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1122        #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1123    ) {
1124        let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1125        let extent2 = ManifestExtents::from_ranges_iter(
1126            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1127                |(extent, dleft, dright)| {
1128                    ((extent.start as i32 + dleft) as u32)
1129                        ..((extent.end as i32 + dright) as u32)
1130                },
1131            ),
1132        );
1133
1134        if all(delta_left.iter(), |elem| elem == &0i32)
1135            && all(delta_right.iter(), |elem| elem == &0i32)
1136        {
1137            prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1138        }
1139
1140        let extent2 = ManifestExtents::from_ranges_iter(
1141            multizip((
1142                extent1.iter(),
1143                widths.iter(),
1144                delta_left.iter(),
1145                delta_right.iter(),
1146            ))
1147            .map(|(extent, width, dleft, dright)| {
1148                let (low, high) = (dleft.min(dright), dleft.max(dright));
1149                ((extent.start as i32 + width + low) as u32)
1150                    ..((extent.end as i32 + width + high) as u32)
1151            }),
1152        );
1153
1154        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1155
1156        let extent2 = ManifestExtents::from_ranges_iter(
1157            multizip((
1158                extent1.iter(),
1159                widths.iter(),
1160                delta_left.iter(),
1161                delta_right.iter(),
1162            ))
1163            .map(|(extent, width, dleft, dright)| {
1164                let (low, high) = (dleft.min(dright), dleft.max(dright));
1165                ((extent.start as i32 - width - high).max(0i32) as u32)
1166                    ..((extent.end as i32 - width - low) as u32)
1167            }),
1168        );
1169
1170        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1171
1172        let extent2 = ManifestExtents::from_ranges_iter(
1173            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1174                |(extent, dleft, dright)| {
1175                    ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1176                        ..((extent.end as i32 + dright + 1) as u32)
1177                },
1178            ),
1179        );
1180        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1181    }
1182
1183    // Regression test for https://github.com/earth-mover/icechunk/issues/2218
1184    // VirtualChunkLocation::from_url used to drop userinfo, port, query and
1185    // fragment from the URL, which corrupts opaque object keys.
1186    // Non-`file://` locations are now stored verbatim: every URL part,
1187    // including repeated `//` and literal `..`, is preserved exactly.
1188    #[icechunk_macros::test]
1189    fn test_from_url_preserves_all_url_parts() -> Result<(), Box<dyn Error>> {
1190        let input = "https://user:pass@host.com:8443/a//b/../c.bin?versionId=42#frag";
1191        let stored = VirtualChunkLocation::from_url(input)?;
1192        assert_eq!(stored.url(), input);
1193        Ok(())
1194    }
1195
1196    // Object-store keys are opaque: `//`, `.` and `..` are literal bytes that
1197    // must survive verbatim, never collapsed by URL normalization.
1198    #[icechunk_macros::test]
1199    fn test_from_url_object_store_keys_are_verbatim() -> Result<(), Box<dyn Error>> {
1200        for input in [
1201            "s3://foo/bar//../baz",
1202            "gcs://b/a//b",
1203            "s3://bucket/key.with.dots/../sibling",
1204            "az://container/a/./b//c",
1205            "tigris://bucket/x//y",
1206            "https://host/a//b/../c.bin?v=1#frag",
1207        ] {
1208            assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1209        }
1210        // userinfo, port, query and fragment all survive untouched too.
1211        let with_parts = "s3://user:pass@host:9000/a//b/../c?x=1#f";
1212        assert_eq!(VirtualChunkLocation::from_url(with_parts)?.url(), with_parts);
1213        Ok(())
1214    }
1215
1216    // `vcc://` relative locations are opaque key suffixes too, so they are also
1217    // stored verbatim (their `//`/`..` must not be normalized before expansion).
1218    #[icechunk_macros::test]
1219    fn test_from_url_vcc_is_verbatim() -> Result<(), Box<dyn Error>> {
1220        let input = "vcc://name/a//b/../c";
1221        assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1222        Ok(())
1223    }
1224
1225    // `file://` is a security boundary: it's processed via normalization, so
1226    // `/../` and `%2e%2e` are resolved away
1227    #[icechunk_macros::test]
1228    fn test_from_url_file_is_normalized() -> Result<(), Box<dyn Error>> {
1229        assert_eq!(
1230            VirtualChunkLocation::from_url("file:///a/b/../c")?.url(),
1231            "file:///a/c"
1232        );
1233        assert_eq!(
1234            VirtualChunkLocation::from_url("file:///authorized/%2e%2e/secret")?.url(),
1235            "file:///secret"
1236        );
1237        Ok(())
1238    }
1239
1240    // Scheme casing is normalized (it is not part of the key), so container
1241    // matching stays stable, but the key bytes are untouched.
1242    #[icechunk_macros::test]
1243    fn test_from_url_lowercases_scheme_only() -> Result<(), Box<dyn Error>> {
1244        assert_eq!(
1245            VirtualChunkLocation::from_url("S3://Bucket/Key//..")?.url(),
1246            "s3://Bucket/Key//.."
1247        );
1248        Ok(())
1249    }
1250
1251    #[icechunk_macros::test]
1252    fn test_from_url_rejects_bad_input() {
1253        use VirtualReferenceErrorKind::*;
1254        let kind =
1255            |s: &str| VirtualChunkLocation::from_url(s).map(|_| ()).unwrap_err().kind;
1256
1257        // Not a URL at all: a precise parse error.
1258        assert!(matches!(kind("not-a-url"), CannotParseUrl { .. }));
1259        assert!(matches!(kind(""), CannotParseUrl { .. }));
1260        // cannot-be-a-base URLs (no authority, no path segments).
1261        assert!(matches!(kind("mailto:foo"), NoPathSegments(_)));
1262        // Has a scheme and an authority slot, but the authority is empty.
1263        assert!(matches!(kind("s3:///key"), CannotParseBucketName(_)));
1264        assert!(matches!(kind("vcc:///rel"), NoContainerForName(_)));
1265        // Single-slash forms parse with a path but no authority/bucket: the
1266        // error must reflect the missing bucket
1267        assert!(matches!(kind("s3:/bucket/key"), CannotParseBucketName(_)));
1268    }
1269
1270    // `file:/x` (single slash, no authority) is accepted and normalized to
1271    // `file:///x`
1272    #[icechunk_macros::test]
1273    fn test_from_url_file_single_slash_accepted() -> Result<(), Box<dyn Error>> {
1274        assert_eq!(VirtualChunkLocation::from_url("file:/x")?.url(), "file:///x");
1275        Ok(())
1276    }
1277
1278    #[icechunk_macros::test]
1279    fn test_overlaps() -> Result<(), Box<dyn Error>> {
1280        let e1 = ManifestExtents::new(
1281            vec![0u32, 1, 2].as_slice(),
1282            vec![2u32, 4, 6].as_slice(),
1283        );
1284
1285        let e2 = ManifestExtents::new(
1286            vec![10u32, 1, 2].as_slice(),
1287            vec![12u32, 4, 6].as_slice(),
1288        );
1289
1290        let union = ManifestExtents::new(
1291            vec![0u32, 1, 2].as_slice(),
1292            vec![12u32, 4, 6].as_slice(),
1293        );
1294
1295        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1296        assert_eq!(e1.intersection(&e2), None);
1297        assert_eq!(e1.union(&e2), union);
1298
1299        let e1 = ManifestExtents::new(
1300            vec![0u32, 1, 2].as_slice(),
1301            vec![2u32, 4, 6].as_slice(),
1302        );
1303        let e2 = ManifestExtents::new(
1304            vec![2u32, 1, 2].as_slice(),
1305            vec![42u32, 4, 6].as_slice(),
1306        );
1307        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1308        assert_eq!(e1.overlap_with(&e2), Overlap::None);
1309
1310        // asymmetric case
1311        let e1 = ManifestExtents::new(
1312            vec![0u32, 1, 2].as_slice(),
1313            vec![3u32, 4, 6].as_slice(),
1314        );
1315        let e2 = ManifestExtents::new(
1316            vec![2u32, 1, 2].as_slice(),
1317            vec![3u32, 4, 6].as_slice(),
1318        );
1319        let union = ManifestExtents::new(
1320            vec![0u32, 1, 2].as_slice(),
1321            vec![3u32, 4, 6].as_slice(),
1322        );
1323        let intersection = ManifestExtents::new(
1324            vec![2u32, 1, 2].as_slice(),
1325            vec![3u32, 4, 6].as_slice(),
1326        );
1327        assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1328        assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1329        assert_eq!(e1.union(&e2), union.clone());
1330        assert_eq!(e2.union(&e1), union.clone());
1331        assert_eq!(e1.intersection(&e2), Some(intersection));
1332
1333        // empty set
1334        let e1 = ManifestExtents::new(
1335            vec![0u32, 1, 2].as_slice(),
1336            vec![3u32, 4, 6].as_slice(),
1337        );
1338        let e2 = ManifestExtents::new(
1339            vec![2u32, 1, 2].as_slice(),
1340            vec![2u32, 4, 6].as_slice(),
1341        );
1342        assert_eq!(e1.intersection(&e2), None);
1343
1344        // this should create non-overlapping extents
1345        let splits = ManifestSplits::from_edges(vec![
1346            vec![0, 10, 20],
1347            vec![0, 1, 2],
1348            vec![0, 21, 22],
1349        ]);
1350        for vec in splits.iter().combinations(2) {
1351            assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1352            assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1353        }
1354
1355        Ok(())
1356    }
1357
1358    #[alt_proptest]
1359    fn test_manifest_split_from_edges(
1360        #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1361    ) {
1362        // Note: using the shape, chunks strategy to generate chunk_shape, split_shape
1363        let ShapeDim { shape, .. } = shape_dim;
1364
1365        let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1366        let split_shape: Vec<u64> = shape
1367            .iter()
1368            .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1369            .collect();
1370
1371        let ndim = shape.len();
1372        let edges: Vec<Vec<u32>> = (0usize..ndim)
1373            .map(|axis| {
1374                uniform_manifest_split_edges(
1375                    num_chunks[axis],
1376                    &(split_shape[axis] as u32),
1377                )
1378            })
1379            .collect();
1380
1381        let splits = ManifestSplits::from_edges(edges.into_iter());
1382        for edge in splits.iter() {
1383            // must be ndim ranges
1384            prop_assert_eq!(edge.len(), ndim);
1385            for range in edge.iter() {
1386                prop_assert!(range.end > range.start);
1387            }
1388        }
1389
1390        // when using from_edges, extents must not exactly overlap
1391        for edges in splits.iter().combinations(2) {
1392            let is_equal =
1393                zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1394                    (range1.start == range2.start) && (range1.end == range2.end)
1395                });
1396            prop_assert!(!is_equal);
1397        }
1398    }
1399
1400    const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1401        min_num_chunks: 10,
1402        dictionary_max_training_samples: 500,
1403        dictionary_max_size_bytes: 16 * 1024,
1404        compression_level: 3,
1405    };
1406
1407    fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1408        let node = NodeId::random();
1409        (0..n)
1410            .map(|i| ChunkInfo {
1411                node: node.clone(),
1412                coord: ChunkIndices(vec![i as u32]),
1413                payload: ChunkPayload::Virtual(VirtualChunkRef {
1414                    location: VirtualChunkLocation::from_url(&format!(
1415                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1416                    ))
1417                    .unwrap(),
1418                    offset: i as u64 * 1024,
1419                    length: 1024,
1420                    checksum: None,
1421                }),
1422            })
1423            .collect()
1424    }
1425
1426    #[tokio_test]
1427    async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1428        // >= threshold virtual chunks with compress=true should round-trip
1429        let chunks = make_virtual_chunks(50);
1430        let manifest = Manifest::from_iter(
1431            &ManifestId::random(),
1432            chunks.clone(),
1433            Some(&COMPRESS_CONFIG),
1434        )
1435        .await?
1436        .unwrap();
1437
1438        let root = manifest.root();
1439        assert!(root.location_dictionary().is_some());
1440        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1441        for am in root.arrays().iter() {
1442            for r in am.refs().iter() {
1443                assert!(r.compressed_location().is_some());
1444                assert!(r.location().is_none());
1445            }
1446        }
1447
1448        for chunk in &chunks {
1449            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1450            assert_eq!(payload, chunk.payload);
1451        }
1452        Ok(())
1453    }
1454
1455    #[tokio_test]
1456    async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1457        // Below threshold: compress=true but few chunks → no compression, readback works
1458        let chunks = make_virtual_chunks(5);
1459        let manifest = Manifest::from_iter(
1460            &ManifestId::random(),
1461            chunks.clone(),
1462            Some(&COMPRESS_CONFIG),
1463        )
1464        .await?
1465        .unwrap();
1466
1467        let root = manifest.root();
1468        assert!(root.location_dictionary().is_none());
1469        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1470        for am in root.arrays().iter() {
1471            for r in am.refs().iter() {
1472                assert!(r.compressed_location().is_none());
1473                assert!(r.location().is_some());
1474            }
1475        }
1476
1477        for chunk in &chunks {
1478            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1479            assert_eq!(payload, chunk.payload);
1480        }
1481        Ok(())
1482    }
1483
1484    #[tokio_test]
1485    async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1486        // Mix of virtual, inline, and ref payloads with compression
1487        let node = NodeId::random();
1488        let mut chunks: Vec<ChunkInfo> = (0..30)
1489            .map(|i| ChunkInfo {
1490                node: node.clone(),
1491                coord: ChunkIndices(vec![i]),
1492                payload: ChunkPayload::Virtual(VirtualChunkRef {
1493                    location: VirtualChunkLocation::from_url(&format!(
1494                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1495                    ))
1496                    .unwrap(),
1497                    offset: i as u64 * 1024,
1498                    length: 1024,
1499                    checksum: None,
1500                }),
1501            })
1502            .collect();
1503        chunks.push(ChunkInfo {
1504            node: node.clone(),
1505            coord: ChunkIndices(vec![100]),
1506            payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1507        });
1508        chunks.push(ChunkInfo {
1509            node: node.clone(),
1510            coord: ChunkIndices(vec![101]),
1511            payload: ChunkPayload::Ref(ChunkRef {
1512                id: ChunkId::random(),
1513                offset: 0,
1514                length: 512,
1515            }),
1516        });
1517
1518        let manifest = Manifest::from_iter(
1519            &ManifestId::random(),
1520            chunks.clone(),
1521            Some(&COMPRESS_CONFIG),
1522        )
1523        .await?
1524        .unwrap();
1525
1526        for chunk in &chunks {
1527            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1528            assert_eq!(payload, chunk.payload);
1529        }
1530        Ok(())
1531    }
1532
1533    #[tokio_test]
1534    async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1535        // bytes() → from_buffer() round-trip
1536        let chunks = make_virtual_chunks(50);
1537        let manifest = Manifest::from_iter(
1538            &ManifestId::random(),
1539            chunks.clone(),
1540            Some(&COMPRESS_CONFIG),
1541        )
1542        .await?
1543        .unwrap();
1544        let bytes = manifest.bytes().to_vec();
1545        let manifest2 = Manifest::from_buffer(bytes)?;
1546
1547        for chunk in &chunks {
1548            let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1549            assert_eq!(payload, chunk.payload);
1550        }
1551        Ok(())
1552    }
1553
1554    #[tokio_test]
1555    async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1556        // Iterator-based access with compression
1557        let chunks = make_virtual_chunks(50);
1558        let node = chunks[0].node.clone();
1559        let manifest = Arc::new(
1560            Manifest::from_iter(
1561                &ManifestId::random(),
1562                chunks.clone(),
1563                Some(&COMPRESS_CONFIG),
1564            )
1565            .await?
1566            .unwrap(),
1567        );
1568
1569        let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1570        assert_eq!(iter_results.len(), 50);
1571        for (i, result) in iter_results.into_iter().enumerate() {
1572            let (coord, payload) = result?;
1573            assert_eq!(coord, chunks[i].coord);
1574            assert_eq!(payload, chunks[i].payload);
1575        }
1576        Ok(())
1577    }
1578
1579    #[tokio_test]
1580    async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1581        // compress=false → locations in `location` field, no compressed_location, no dictionary
1582        let chunks = make_virtual_chunks(50);
1583        let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1584            .await?
1585            .unwrap();
1586
1587        let root = manifest.root();
1588        assert!(root.location_dictionary().is_none());
1589        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1590
1591        // Verify location field is populated (not compressed_location)
1592        for am in root.arrays().iter() {
1593            for r in am.refs().iter() {
1594                if r.chunk_id().is_none() && r.inline().is_none() {
1595                    assert!(r.location().is_some(), "location should be set");
1596                    assert!(
1597                        r.compressed_location().is_none(),
1598                        "compressed_location should be None"
1599                    );
1600                }
1601            }
1602        }
1603
1604        // Readback still works
1605        for chunk in &chunks {
1606            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1607            assert_eq!(payload, chunk.payload);
1608        }
1609        Ok(())
1610    }
1611
1612    #[tokio_test]
1613    async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1614        // compress=true with enough virtual chunks → compressed_location, dictionary present
1615        let chunks = make_virtual_chunks(50);
1616        let manifest = Manifest::from_iter(
1617            &ManifestId::random(),
1618            chunks.clone(),
1619            Some(&COMPRESS_CONFIG),
1620        )
1621        .await?
1622        .unwrap();
1623
1624        let root = manifest.root();
1625        assert!(root.location_dictionary().is_some());
1626        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1627
1628        // Verify compressed_location is populated and location is None
1629        for am in root.arrays().iter() {
1630            for r in am.refs().iter() {
1631                if r.chunk_id().is_none() && r.inline().is_none() {
1632                    assert!(
1633                        r.compressed_location().is_some(),
1634                        "compressed_location should be set"
1635                    );
1636                    assert!(
1637                        r.location().is_none(),
1638                        "location should be None for compressed chunks"
1639                    );
1640                }
1641            }
1642        }
1643
1644        // Readback still works
1645        for chunk in &chunks {
1646            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1647            assert_eq!(payload, chunk.payload);
1648        }
1649        Ok(())
1650    }
1651
1652    #[tokio_test]
1653    async fn test_compression_skipped_when_locations_too_short()
1654    -> Result<(), Box<dyn Error>> {
1655        // When most virtual chunk locations are < 8 bytes, dictionary training
1656        // is skipped and the manifest falls back to uncompressed storage.
1657        // With longer locations (>= 8 bytes), compression kicks in.
1658        let config = LocationCompressionConfig {
1659            min_num_chunks: 2,
1660            dictionary_max_training_samples: 500,
1661            dictionary_max_size_bytes: 256,
1662            compression_level: 3,
1663        };
1664
1665        let node = NodeId::random();
1666        // "s3://a/" is 7 bytes, below the 8-byte threshold
1667        let short_chunks: Vec<ChunkInfo> = (0..2)
1668            .map(|i| ChunkInfo {
1669                node: node.clone(),
1670                coord: ChunkIndices(vec![i as u32]),
1671                payload: ChunkPayload::Virtual(VirtualChunkRef {
1672                    location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1673                    offset: i as u64 * 1024,
1674                    length: 1024,
1675                    checksum: None,
1676                }),
1677            })
1678            .collect();
1679
1680        let manifest = Manifest::from_iter(
1681            &ManifestId::random(),
1682            short_chunks.clone(),
1683            Some(&config),
1684        )
1685        .await?
1686        .unwrap();
1687
1688        let root = manifest.root();
1689        assert!(root.location_dictionary().is_none());
1690        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1691        for am in root.arrays().iter() {
1692            for r in am.refs().iter() {
1693                assert!(r.compressed_location().is_none());
1694                assert!(r.location().is_some());
1695            }
1696        }
1697
1698        for chunk in &short_chunks {
1699            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1700            assert_eq!(payload, chunk.payload);
1701        }
1702
1703        // "s3://abc/" is 9 bytes, above the 8-byte threshold — compression should apply
1704        let long_chunks: Vec<ChunkInfo> = (0..2)
1705            .map(|i| ChunkInfo {
1706                node: node.clone(),
1707                coord: ChunkIndices(vec![i as u32]),
1708                payload: ChunkPayload::Virtual(VirtualChunkRef {
1709                    location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1710                    offset: i as u64 * 1024,
1711                    length: 1024,
1712                    checksum: None,
1713                }),
1714            })
1715            .collect();
1716
1717        let manifest = Manifest::from_iter(
1718            &ManifestId::random(),
1719            long_chunks.clone(),
1720            Some(&config),
1721        )
1722        .await?
1723        .unwrap();
1724
1725        let root = manifest.root();
1726        assert!(root.location_dictionary().is_some());
1727        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1728        for am in root.arrays().iter() {
1729            for r in am.refs().iter() {
1730                assert!(r.compressed_location().is_some());
1731                assert!(r.location().is_none());
1732            }
1733        }
1734
1735        for chunk in &long_chunks {
1736            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1737            assert_eq!(payload, chunk.payload);
1738        }
1739        Ok(())
1740    }
1741
1742    #[tokio_test]
1743    async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1744        // 100 virtual chunks with highly redundant, large location strings.
1745        // Compression should produce a significantly smaller manifest.
1746        let node = NodeId::random();
1747        let chunks: Vec<ChunkInfo> = (0..100)
1748            .map(|i| ChunkInfo {
1749                node: node.clone(),
1750                coord: ChunkIndices(vec![i as u32]),
1751                payload: ChunkPayload::Virtual(VirtualChunkRef {
1752                    location: VirtualChunkLocation::from_url(&format!(
1753                        "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"
1754                    ))
1755                    .unwrap(),
1756                    offset: i as u64 * 4096,
1757                    length: 4096,
1758                    checksum: None,
1759                }),
1760            })
1761            .collect();
1762
1763        // Build without compression
1764        let manifest_uncompressed =
1765            Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1766                .await?
1767                .unwrap();
1768        let size_uncompressed = manifest_uncompressed.bytes().len();
1769
1770        assert!(!manifest_uncompressed.uses_location_compression());
1771        assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1772
1773        // Build with compression
1774        let manifest_compressed = Manifest::from_iter(
1775            &ManifestId::random(),
1776            chunks.clone(),
1777            Some(&COMPRESS_CONFIG),
1778        )
1779        .await?
1780        .unwrap();
1781        let size_compressed = manifest_compressed.bytes().len();
1782
1783        assert!(manifest_compressed.uses_location_compression());
1784        assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1785        assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1786
1787        // Each compressed_location should be much shorter than the raw URL (118 bytes)
1788        let root = manifest_compressed.root();
1789        for am in root.arrays().iter() {
1790            for r in am.refs().iter() {
1791                let compressed = r.compressed_location().unwrap();
1792                assert!(
1793                    compressed.len() < 40,
1794                    "compressed_location ({} bytes) should be under 40 bytes \
1795                     (raw location is 118 bytes)",
1796                    compressed.len(),
1797                );
1798            }
1799        }
1800
1801        // Compressed manifest should be meaningfully smaller
1802        assert!(
1803            size_compressed < size_uncompressed,
1804            "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1805        );
1806
1807        let saving_pct =
1808            (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1809        assert!(
1810            saving_pct > 15.0,
1811            "expected at least 15% size reduction with redundant locations, \
1812             got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1813        );
1814
1815        // Both manifests must round-trip correctly
1816        for chunk in &chunks {
1817            let p1 = manifest_uncompressed
1818                .get_chunk_payload(&chunk.node, &chunk.coord)
1819                .unwrap();
1820            let p2 =
1821                manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1822            assert_eq!(p1, chunk.payload);
1823            assert_eq!(p2, chunk.payload);
1824        }
1825
1826        Ok(())
1827    }
1828
1829    #[tokio_test]
1830    async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1831        // chunk_payloads() works with compressed manifests
1832        let chunks = make_virtual_chunks(50);
1833        let manifest = Manifest::from_iter(
1834            &ManifestId::random(),
1835            chunks.clone(),
1836            Some(&COMPRESS_CONFIG),
1837        )
1838        .await?
1839        .unwrap();
1840
1841        let payloads: Vec<_> =
1842            manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1843        assert_eq!(payloads.len(), 50);
1844        for (i, payload) in payloads.iter().enumerate() {
1845            assert_eq!(payload, &chunks[i].payload);
1846        }
1847        Ok(())
1848    }
1849}