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
880    #[cfg(not(target_family = "wasm"))]
881    {
882        let num_threads =
883            std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
884        let slice_size = chunks.len().div_ceil(num_threads);
885
886        std::thread::scope(|s| {
887            let handles: Vec<_> = chunks
888                .chunks(slice_size)
889                .map(|slice| {
890                    s.spawn(|| {
891                        let mut comp = zstd::bulk::Compressor::with_dictionary(
892                            compression_level,
893                            dict,
894                        )
895                        .ok();
896                        slice
897                            .iter()
898                            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
899                                (ChunkPayload::Virtual(vref), Some(comp)) => {
900                                    comp.compress(vref.location.url().as_bytes()).ok()
901                                }
902                                _ => None,
903                            })
904                            .collect::<Vec<_>>()
905                    })
906                })
907                .collect();
908
909            #[expect(clippy::expect_used)]
910            handles
911                .into_iter()
912                .flat_map(|h| {
913                    h.join()
914                        .expect("Cannot join threads compressing virtual chunk locations")
915                })
916                .collect()
917        })
918    }
919
920    #[cfg(target_family = "wasm")]
921    {
922        let mut comp =
923            zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
924        chunks
925            .iter()
926            .map(|chunk| match (&chunk.payload, comp.as_mut()) {
927                (ChunkPayload::Virtual(vref), Some(comp)) => {
928                    comp.compress(vref.location.url().as_bytes()).ok()
929                }
930                _ => None,
931            })
932            .collect()
933    }
934}
935
936fn mk_chunk_ref<'bldr>(
937    builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
938    chunk: ChunkInfo,
939    precompressed_location: Option<Vec<u8>>,
940) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
941    let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
942    match chunk.payload {
943        ChunkPayload::Inline(bytes) => {
944            let bytes = builder.create_vector(bytes.as_ref());
945            let args = generated::ChunkRefArgs {
946                inline: Some(bytes),
947                index,
948                ..Default::default()
949            };
950            generated::ChunkRef::create(builder, &args)
951        }
952        ChunkPayload::Virtual(virtual_chunk_ref) => {
953            let location_str = virtual_chunk_ref.location.0.as_str();
954            let (location, compressed_location) =
955                if let Some(compressed) = precompressed_location {
956                    (None, Some(builder.create_vector(&compressed)))
957                } else {
958                    (Some(builder.create_string(location_str)), None)
959                };
960            let args = generated::ChunkRefArgs {
961                index,
962                location,
963                compressed_location,
964                offset: virtual_chunk_ref.offset,
965                length: virtual_chunk_ref.length,
966                checksum_etag: match &virtual_chunk_ref.checksum {
967                    Some(cs) => match cs {
968                        Checksum::LastModified(_) => None,
969                        Checksum::ETag(etag) => {
970                            Some(builder.create_string(etag.0.as_str()))
971                        }
972                    },
973                    None => None,
974                },
975                checksum_last_modified: match &virtual_chunk_ref.checksum {
976                    Some(cs) => match cs {
977                        Checksum::LastModified(seconds) => seconds.0,
978                        Checksum::ETag(_) => 0,
979                    },
980                    None => 0,
981                },
982                ..Default::default()
983            };
984            generated::ChunkRef::create(builder, &args)
985        }
986        ChunkPayload::Ref(chunk_ref) => {
987            let id = generated::ObjectId12::new(&chunk_ref.id.0);
988            let args = generated::ChunkRefArgs {
989                index,
990                offset: chunk_ref.offset,
991                length: chunk_ref.length,
992                chunk_id: Some(&id),
993                ..Default::default()
994            };
995            generated::ChunkRef::create(builder, &args)
996        }
997    }
998}
999
1000static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1001    max_depth: 64,
1002    max_tables: 500_000_000,
1003    max_apparent_size: 1 << 31, // taken from the default
1004    ignore_missing_null_terminator: true,
1005};
1006
1007#[cfg(test)]
1008#[expect(unused_qualifications)] // proptest macros generate fully qualified paths
1009mod tests {
1010    use super::*;
1011    use crate::roundtrip_serialization_tests;
1012    use crate::strategies::{
1013        ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1014        manifest_splits, shapes_and_dims,
1015    };
1016    use icechunk_macros::{self, tokio_test};
1017    use itertools::{all, multizip};
1018    use proptest::collection::vec;
1019    use proptest::prelude::*;
1020    use std::error::Error;
1021    use test_strategy::proptest as alt_proptest;
1022
1023    roundtrip_serialization_tests!(
1024        serialize_and_deserialize_manifest_ref - manifest_ref,
1025        serialize_and_deserialize_manifest_splits - manifest_splits
1026    );
1027
1028    #[alt_proptest]
1029    fn test_property_extents_set_ops_same(
1030        #[strategy(manifest_extents(4))] e: ManifestExtents,
1031    ) {
1032        prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1033        prop_assert_eq!(e.union(&e), e.clone());
1034        prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1035    }
1036
1037    #[alt_proptest]
1038    fn test_property_extents_set_ops(
1039        #[strategy(manifest_extents(4))] e1: ManifestExtents,
1040        #[strategy(manifest_extents(4))] e2: ManifestExtents,
1041    ) {
1042        let union = e1.union(&e2);
1043        let intersection = e1.intersection(&e2);
1044
1045        prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1046        prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1047        prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1048        prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1049
1050        // order is important for the next 2
1051        prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1052        prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1053
1054        if intersection.is_some() {
1055            let int = intersection.unwrap();
1056            let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1057            prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1058            prop_assert_eq!(int.overlap_with(&e2), expected);
1059        } else {
1060            prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1061            prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1062        }
1063    }
1064
1065    #[alt_proptest]
1066    fn test_property_extents_widths(
1067        #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1068        #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1069        #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1070    ) {
1071        let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1072        let extent2 = ManifestExtents::from_ranges_iter(
1073            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1074                |(extent, dleft, dright)| {
1075                    ((extent.start as i32 + dleft) as u32)
1076                        ..((extent.end as i32 + dright) as u32)
1077                },
1078            ),
1079        );
1080
1081        if all(delta_left.iter(), |elem| elem == &0i32)
1082            && all(delta_right.iter(), |elem| elem == &0i32)
1083        {
1084            prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1085        }
1086
1087        let extent2 = ManifestExtents::from_ranges_iter(
1088            multizip((
1089                extent1.iter(),
1090                widths.iter(),
1091                delta_left.iter(),
1092                delta_right.iter(),
1093            ))
1094            .map(|(extent, width, dleft, dright)| {
1095                let (low, high) = (dleft.min(dright), dleft.max(dright));
1096                ((extent.start as i32 + width + low) as u32)
1097                    ..((extent.end as i32 + width + high) as u32)
1098            }),
1099        );
1100
1101        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1102
1103        let extent2 = ManifestExtents::from_ranges_iter(
1104            multizip((
1105                extent1.iter(),
1106                widths.iter(),
1107                delta_left.iter(),
1108                delta_right.iter(),
1109            ))
1110            .map(|(extent, width, dleft, dright)| {
1111                let (low, high) = (dleft.min(dright), dleft.max(dright));
1112                ((extent.start as i32 - width - high).max(0i32) as u32)
1113                    ..((extent.end as i32 - width - low) as u32)
1114            }),
1115        );
1116
1117        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1118
1119        let extent2 = ManifestExtents::from_ranges_iter(
1120            multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1121                |(extent, dleft, dright)| {
1122                    ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1123                        ..((extent.end as i32 + dright + 1) as u32)
1124                },
1125            ),
1126        );
1127        prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1128    }
1129
1130    #[icechunk_macros::test]
1131    fn test_overlaps() -> Result<(), Box<dyn Error>> {
1132        let e1 = ManifestExtents::new(
1133            vec![0u32, 1, 2].as_slice(),
1134            vec![2u32, 4, 6].as_slice(),
1135        );
1136
1137        let e2 = ManifestExtents::new(
1138            vec![10u32, 1, 2].as_slice(),
1139            vec![12u32, 4, 6].as_slice(),
1140        );
1141
1142        let union = ManifestExtents::new(
1143            vec![0u32, 1, 2].as_slice(),
1144            vec![12u32, 4, 6].as_slice(),
1145        );
1146
1147        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1148        assert_eq!(e1.intersection(&e2), None);
1149        assert_eq!(e1.union(&e2), union);
1150
1151        let e1 = ManifestExtents::new(
1152            vec![0u32, 1, 2].as_slice(),
1153            vec![2u32, 4, 6].as_slice(),
1154        );
1155        let e2 = ManifestExtents::new(
1156            vec![2u32, 1, 2].as_slice(),
1157            vec![42u32, 4, 6].as_slice(),
1158        );
1159        assert_eq!(e2.overlap_with(&e1), Overlap::None);
1160        assert_eq!(e1.overlap_with(&e2), Overlap::None);
1161
1162        // asymmetric case
1163        let e1 = ManifestExtents::new(
1164            vec![0u32, 1, 2].as_slice(),
1165            vec![3u32, 4, 6].as_slice(),
1166        );
1167        let e2 = ManifestExtents::new(
1168            vec![2u32, 1, 2].as_slice(),
1169            vec![3u32, 4, 6].as_slice(),
1170        );
1171        let union = ManifestExtents::new(
1172            vec![0u32, 1, 2].as_slice(),
1173            vec![3u32, 4, 6].as_slice(),
1174        );
1175        let intersection = ManifestExtents::new(
1176            vec![2u32, 1, 2].as_slice(),
1177            vec![3u32, 4, 6].as_slice(),
1178        );
1179        assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1180        assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1181        assert_eq!(e1.union(&e2), union.clone());
1182        assert_eq!(e2.union(&e1), union.clone());
1183        assert_eq!(e1.intersection(&e2), Some(intersection));
1184
1185        // empty set
1186        let e1 = ManifestExtents::new(
1187            vec![0u32, 1, 2].as_slice(),
1188            vec![3u32, 4, 6].as_slice(),
1189        );
1190        let e2 = ManifestExtents::new(
1191            vec![2u32, 1, 2].as_slice(),
1192            vec![2u32, 4, 6].as_slice(),
1193        );
1194        assert_eq!(e1.intersection(&e2), None);
1195
1196        // this should create non-overlapping extents
1197        let splits = ManifestSplits::from_edges(vec![
1198            vec![0, 10, 20],
1199            vec![0, 1, 2],
1200            vec![0, 21, 22],
1201        ]);
1202        for vec in splits.iter().combinations(2) {
1203            assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1204            assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1205        }
1206
1207        Ok(())
1208    }
1209
1210    #[alt_proptest]
1211    fn test_manifest_split_from_edges(
1212        #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1213    ) {
1214        // Note: using the shape, chunks strategy to generate chunk_shape, split_shape
1215        let ShapeDim { shape, .. } = shape_dim;
1216
1217        let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1218        let split_shape: Vec<u64> = shape
1219            .iter()
1220            .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1221            .collect();
1222
1223        let ndim = shape.len();
1224        let edges: Vec<Vec<u32>> = (0usize..ndim)
1225            .map(|axis| {
1226                uniform_manifest_split_edges(
1227                    num_chunks[axis],
1228                    &(split_shape[axis] as u32),
1229                )
1230            })
1231            .collect();
1232
1233        let splits = ManifestSplits::from_edges(edges.into_iter());
1234        for edge in splits.iter() {
1235            // must be ndim ranges
1236            prop_assert_eq!(edge.len(), ndim);
1237            for range in edge.iter() {
1238                prop_assert!(range.end > range.start);
1239            }
1240        }
1241
1242        // when using from_edges, extents must not exactly overlap
1243        for edges in splits.iter().combinations(2) {
1244            let is_equal =
1245                zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1246                    (range1.start == range2.start) && (range1.end == range2.end)
1247                });
1248            prop_assert!(!is_equal);
1249        }
1250    }
1251
1252    const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1253        min_num_chunks: 10,
1254        dictionary_max_training_samples: 500,
1255        dictionary_max_size_bytes: 16 * 1024,
1256        compression_level: 3,
1257    };
1258
1259    fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1260        let node = NodeId::random();
1261        (0..n)
1262            .map(|i| ChunkInfo {
1263                node: node.clone(),
1264                coord: ChunkIndices(vec![i as u32]),
1265                payload: ChunkPayload::Virtual(VirtualChunkRef {
1266                    location: VirtualChunkLocation::from_url(&format!(
1267                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1268                    ))
1269                    .unwrap(),
1270                    offset: i as u64 * 1024,
1271                    length: 1024,
1272                    checksum: None,
1273                }),
1274            })
1275            .collect()
1276    }
1277
1278    #[tokio_test]
1279    async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1280        // >= threshold virtual chunks with compress=true should round-trip
1281        let chunks = make_virtual_chunks(50);
1282        let manifest = Manifest::from_iter(
1283            &ManifestId::random(),
1284            chunks.clone(),
1285            Some(&COMPRESS_CONFIG),
1286        )
1287        .await?
1288        .unwrap();
1289
1290        let root = manifest.root();
1291        assert!(root.location_dictionary().is_some());
1292        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1293        for am in root.arrays().iter() {
1294            for r in am.refs().iter() {
1295                assert!(r.compressed_location().is_some());
1296                assert!(r.location().is_none());
1297            }
1298        }
1299
1300        for chunk in &chunks {
1301            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1302            assert_eq!(payload, chunk.payload);
1303        }
1304        Ok(())
1305    }
1306
1307    #[tokio_test]
1308    async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1309        // Below threshold: compress=true but few chunks → no compression, readback works
1310        let chunks = make_virtual_chunks(5);
1311        let manifest = Manifest::from_iter(
1312            &ManifestId::random(),
1313            chunks.clone(),
1314            Some(&COMPRESS_CONFIG),
1315        )
1316        .await?
1317        .unwrap();
1318
1319        let root = manifest.root();
1320        assert!(root.location_dictionary().is_none());
1321        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1322        for am in root.arrays().iter() {
1323            for r in am.refs().iter() {
1324                assert!(r.compressed_location().is_none());
1325                assert!(r.location().is_some());
1326            }
1327        }
1328
1329        for chunk in &chunks {
1330            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1331            assert_eq!(payload, chunk.payload);
1332        }
1333        Ok(())
1334    }
1335
1336    #[tokio_test]
1337    async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1338        // Mix of virtual, inline, and ref payloads with compression
1339        let node = NodeId::random();
1340        let mut chunks: Vec<ChunkInfo> = (0..30)
1341            .map(|i| ChunkInfo {
1342                node: node.clone(),
1343                coord: ChunkIndices(vec![i]),
1344                payload: ChunkPayload::Virtual(VirtualChunkRef {
1345                    location: VirtualChunkLocation::from_url(&format!(
1346                        "s3://my-bucket/path/to/data/chunk_{i:06}"
1347                    ))
1348                    .unwrap(),
1349                    offset: i as u64 * 1024,
1350                    length: 1024,
1351                    checksum: None,
1352                }),
1353            })
1354            .collect();
1355        chunks.push(ChunkInfo {
1356            node: node.clone(),
1357            coord: ChunkIndices(vec![100]),
1358            payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1359        });
1360        chunks.push(ChunkInfo {
1361            node: node.clone(),
1362            coord: ChunkIndices(vec![101]),
1363            payload: ChunkPayload::Ref(ChunkRef {
1364                id: ChunkId::random(),
1365                offset: 0,
1366                length: 512,
1367            }),
1368        });
1369
1370        let manifest = Manifest::from_iter(
1371            &ManifestId::random(),
1372            chunks.clone(),
1373            Some(&COMPRESS_CONFIG),
1374        )
1375        .await?
1376        .unwrap();
1377
1378        for chunk in &chunks {
1379            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1380            assert_eq!(payload, chunk.payload);
1381        }
1382        Ok(())
1383    }
1384
1385    #[tokio_test]
1386    async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1387        // bytes() → from_buffer() round-trip
1388        let chunks = make_virtual_chunks(50);
1389        let manifest = Manifest::from_iter(
1390            &ManifestId::random(),
1391            chunks.clone(),
1392            Some(&COMPRESS_CONFIG),
1393        )
1394        .await?
1395        .unwrap();
1396        let bytes = manifest.bytes().to_vec();
1397        let manifest2 = Manifest::from_buffer(bytes)?;
1398
1399        for chunk in &chunks {
1400            let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1401            assert_eq!(payload, chunk.payload);
1402        }
1403        Ok(())
1404    }
1405
1406    #[tokio_test]
1407    async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1408        // Iterator-based access with compression
1409        let chunks = make_virtual_chunks(50);
1410        let node = chunks[0].node.clone();
1411        let manifest = Arc::new(
1412            Manifest::from_iter(
1413                &ManifestId::random(),
1414                chunks.clone(),
1415                Some(&COMPRESS_CONFIG),
1416            )
1417            .await?
1418            .unwrap(),
1419        );
1420
1421        let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1422        assert_eq!(iter_results.len(), 50);
1423        for (i, result) in iter_results.into_iter().enumerate() {
1424            let (coord, payload) = result?;
1425            assert_eq!(coord, chunks[i].coord);
1426            assert_eq!(payload, chunks[i].payload);
1427        }
1428        Ok(())
1429    }
1430
1431    #[tokio_test]
1432    async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1433        // compress=false → locations in `location` field, no compressed_location, no dictionary
1434        let chunks = make_virtual_chunks(50);
1435        let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1436            .await?
1437            .unwrap();
1438
1439        let root = manifest.root();
1440        assert!(root.location_dictionary().is_none());
1441        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1442
1443        // Verify location field is populated (not compressed_location)
1444        for am in root.arrays().iter() {
1445            for r in am.refs().iter() {
1446                if r.chunk_id().is_none() && r.inline().is_none() {
1447                    assert!(r.location().is_some(), "location should be set");
1448                    assert!(
1449                        r.compressed_location().is_none(),
1450                        "compressed_location should be None"
1451                    );
1452                }
1453            }
1454        }
1455
1456        // Readback still works
1457        for chunk in &chunks {
1458            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1459            assert_eq!(payload, chunk.payload);
1460        }
1461        Ok(())
1462    }
1463
1464    #[tokio_test]
1465    async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1466        // compress=true with enough virtual chunks → compressed_location, dictionary present
1467        let chunks = make_virtual_chunks(50);
1468        let manifest = Manifest::from_iter(
1469            &ManifestId::random(),
1470            chunks.clone(),
1471            Some(&COMPRESS_CONFIG),
1472        )
1473        .await?
1474        .unwrap();
1475
1476        let root = manifest.root();
1477        assert!(root.location_dictionary().is_some());
1478        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1479
1480        // Verify compressed_location is populated and location is None
1481        for am in root.arrays().iter() {
1482            for r in am.refs().iter() {
1483                if r.chunk_id().is_none() && r.inline().is_none() {
1484                    assert!(
1485                        r.compressed_location().is_some(),
1486                        "compressed_location should be set"
1487                    );
1488                    assert!(
1489                        r.location().is_none(),
1490                        "location should be None for compressed chunks"
1491                    );
1492                }
1493            }
1494        }
1495
1496        // Readback still works
1497        for chunk in &chunks {
1498            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1499            assert_eq!(payload, chunk.payload);
1500        }
1501        Ok(())
1502    }
1503
1504    #[tokio_test]
1505    async fn test_compression_skipped_when_locations_too_short()
1506    -> Result<(), Box<dyn Error>> {
1507        // When most virtual chunk locations are < 8 bytes, dictionary training
1508        // is skipped and the manifest falls back to uncompressed storage.
1509        // With longer locations (>= 8 bytes), compression kicks in.
1510        let config = LocationCompressionConfig {
1511            min_num_chunks: 2,
1512            dictionary_max_training_samples: 500,
1513            dictionary_max_size_bytes: 256,
1514            compression_level: 3,
1515        };
1516
1517        let node = NodeId::random();
1518        // "s3://a/" is 7 bytes, below the 8-byte threshold
1519        let short_chunks: Vec<ChunkInfo> = (0..2)
1520            .map(|i| ChunkInfo {
1521                node: node.clone(),
1522                coord: ChunkIndices(vec![i as u32]),
1523                payload: ChunkPayload::Virtual(VirtualChunkRef {
1524                    location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1525                    offset: i as u64 * 1024,
1526                    length: 1024,
1527                    checksum: None,
1528                }),
1529            })
1530            .collect();
1531
1532        let manifest = Manifest::from_iter(
1533            &ManifestId::random(),
1534            short_chunks.clone(),
1535            Some(&config),
1536        )
1537        .await?
1538        .unwrap();
1539
1540        let root = manifest.root();
1541        assert!(root.location_dictionary().is_none());
1542        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1543        for am in root.arrays().iter() {
1544            for r in am.refs().iter() {
1545                assert!(r.compressed_location().is_none());
1546                assert!(r.location().is_some());
1547            }
1548        }
1549
1550        for chunk in &short_chunks {
1551            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1552            assert_eq!(payload, chunk.payload);
1553        }
1554
1555        // "s3://abc/" is 9 bytes, above the 8-byte threshold — compression should apply
1556        let long_chunks: Vec<ChunkInfo> = (0..2)
1557            .map(|i| ChunkInfo {
1558                node: node.clone(),
1559                coord: ChunkIndices(vec![i as u32]),
1560                payload: ChunkPayload::Virtual(VirtualChunkRef {
1561                    location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1562                    offset: i as u64 * 1024,
1563                    length: 1024,
1564                    checksum: None,
1565                }),
1566            })
1567            .collect();
1568
1569        let manifest = Manifest::from_iter(
1570            &ManifestId::random(),
1571            long_chunks.clone(),
1572            Some(&config),
1573        )
1574        .await?
1575        .unwrap();
1576
1577        let root = manifest.root();
1578        assert!(root.location_dictionary().is_some());
1579        assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1580        for am in root.arrays().iter() {
1581            for r in am.refs().iter() {
1582                assert!(r.compressed_location().is_some());
1583                assert!(r.location().is_none());
1584            }
1585        }
1586
1587        for chunk in &long_chunks {
1588            let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1589            assert_eq!(payload, chunk.payload);
1590        }
1591        Ok(())
1592    }
1593
1594    #[tokio_test]
1595    async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1596        // 100 virtual chunks with highly redundant, large location strings.
1597        // Compression should produce a significantly smaller manifest.
1598        let node = NodeId::random();
1599        let chunks: Vec<ChunkInfo> = (0..100)
1600            .map(|i| ChunkInfo {
1601                node: node.clone(),
1602                coord: ChunkIndices(vec![i as u32]),
1603                payload: ChunkPayload::Virtual(VirtualChunkRef {
1604                    location: VirtualChunkLocation::from_url(&format!(
1605                        "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"
1606                    ))
1607                    .unwrap(),
1608                    offset: i as u64 * 4096,
1609                    length: 4096,
1610                    checksum: None,
1611                }),
1612            })
1613            .collect();
1614
1615        // Build without compression
1616        let manifest_uncompressed =
1617            Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1618                .await?
1619                .unwrap();
1620        let size_uncompressed = manifest_uncompressed.bytes().len();
1621
1622        assert!(!manifest_uncompressed.uses_location_compression());
1623        assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1624
1625        // Build with compression
1626        let manifest_compressed = Manifest::from_iter(
1627            &ManifestId::random(),
1628            chunks.clone(),
1629            Some(&COMPRESS_CONFIG),
1630        )
1631        .await?
1632        .unwrap();
1633        let size_compressed = manifest_compressed.bytes().len();
1634
1635        assert!(manifest_compressed.uses_location_compression());
1636        assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1637        assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1638
1639        // Each compressed_location should be much shorter than the raw URL (118 bytes)
1640        let root = manifest_compressed.root();
1641        for am in root.arrays().iter() {
1642            for r in am.refs().iter() {
1643                let compressed = r.compressed_location().unwrap();
1644                assert!(
1645                    compressed.len() < 40,
1646                    "compressed_location ({} bytes) should be under 40 bytes \
1647                     (raw location is 118 bytes)",
1648                    compressed.len(),
1649                );
1650            }
1651        }
1652
1653        // Compressed manifest should be meaningfully smaller
1654        assert!(
1655            size_compressed < size_uncompressed,
1656            "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1657        );
1658
1659        let saving_pct =
1660            (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1661        assert!(
1662            saving_pct > 15.0,
1663            "expected at least 15% size reduction with redundant locations, \
1664             got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1665        );
1666
1667        // Both manifests must round-trip correctly
1668        for chunk in &chunks {
1669            let p1 = manifest_uncompressed
1670                .get_chunk_payload(&chunk.node, &chunk.coord)
1671                .unwrap();
1672            let p2 =
1673                manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1674            assert_eq!(p1, chunk.payload);
1675            assert_eq!(p2, chunk.payload);
1676        }
1677
1678        Ok(())
1679    }
1680
1681    #[tokio_test]
1682    async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1683        // chunk_payloads() works with compressed manifests
1684        let chunks = make_virtual_chunks(50);
1685        let manifest = Manifest::from_iter(
1686            &ManifestId::random(),
1687            chunks.clone(),
1688            Some(&COMPRESS_CONFIG),
1689        )
1690        .await?
1691        .unwrap();
1692
1693        let payloads: Vec<_> =
1694            manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1695        assert_eq!(payloads.len(), 50);
1696        for (i, payload) in payloads.iter().enumerate() {
1697            assert_eq!(payload, &chunks[i].payload);
1698        }
1699        Ok(())
1700    }
1701}