1use 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#[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 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 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 = {self:?}, other = {other:?}"
115 );
116 let mut overlap = Overlap::Complete;
117 for (a, b) in zip(other.iter(), self.iter()) {
118 debug_assert!(a.start <= a.end, "Invalid range: {:?}", a.clone());
119 debug_assert!(b.start <= b.end, "Invalid range: {:?}", b.clone());
120 if (a.end <= b.start) || (a.start >= b.end) {
121 return Overlap::None;
122 } else if !((a.start <= b.start) && (a.end >= b.end)) {
123 overlap = Overlap::Partial;
124 }
125 }
126 overlap
127 }
128
129 pub fn matches(&self, other: &ManifestExtents) -> bool {
130 if *self == Self::ALL { true } else { self == other }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct ManifestRef {
138 pub object_id: ManifestId,
139 pub extents: ManifestExtents,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct ManifestSplits(pub Vec<Vec<u32>>);
146
147impl ManifestSplits {
148 pub fn is_empty(&self) -> bool {
149 self.0.is_empty()
150 }
151
152 pub fn from_edges(iter: impl IntoIterator<Item = Vec<u32>>) -> Self {
153 Self(iter.into_iter().collect())
154 }
155
156 pub fn iter(&self) -> impl Iterator<Item = ManifestExtents> {
157 self.0
158 .iter()
159 .cloned()
162 .map(|x| x.into_iter().tuple_windows())
164 .multi_cartesian_product()
167 .map(multiunzip)
170 .map(|(from, to): (Vec<u32>, Vec<u32>)| {
171 ManifestExtents::new(from.as_slice(), to.as_slice())
172 })
173 }
174
175 pub fn len(&self) -> usize {
176 self.0.len()
177 }
178
179 #[inline(always)]
181 pub fn find<'a>(&'a self, coord: &'a ChunkIndices) -> Option<ManifestExtents> {
182 debug_assert_eq!(coord.0.len(), self.0.len());
183 let mut ranges = Vec::with_capacity(self.0.len());
184 for (edges, loc) in self.0.iter().zip(coord.0.iter()) {
185 let bin = edges.partition_point(|&e| e <= *loc);
186 if bin == 0 || bin >= edges.len() {
187 return None;
188 }
189 ranges.push(edges[bin - 1]..edges[bin]);
190 }
191 Some(ManifestExtents::from_ranges_iter(ranges))
192 }
193
194 pub fn compatible_with(&self, other: &Self) -> bool {
195 for ours in self.iter() {
199 if any(other.iter(), |theirs| {
200 ours.overlap_with(&theirs) == Overlap::Partial
201 || theirs.overlap_with(&ours) == Overlap::Partial
202 }) {
203 return false;
204 }
205 }
206 true
207 }
208}
209
210pub fn uniform_manifest_split_edges(num_chunks: u32, split_size: &u32) -> Vec<u32> {
212 (0u32..=num_chunks)
213 .step_by(*split_size as usize)
214 .chain((!num_chunks.is_multiple_of(*split_size)).then_some(num_chunks))
215 .collect()
216}
217
218#[derive(Debug, Error)]
219#[non_exhaustive]
220pub enum VirtualReferenceErrorKind {
221 #[error(
222 "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/"
223 )]
224 NoContainerForUrl(String),
225 #[error("error parsing virtual ref URL: {url:?}")]
226 CannotParseUrl {
227 #[source]
228 cause: url::ParseError,
229 url: String,
230 },
231 #[error("invalid credentials for virtual reference of type {0}")]
232 InvalidCredentials(String),
233 #[error("{}", format_unauthorized_vcc(.url_prefix, .name))]
234 UnauthorizedVirtualChunkContainer { url_prefix: String, name: Option<String> },
235 #[error("virtual reference has no path segments {0}")]
236 NoPathSegments(String),
237 #[error("unsupported scheme for virtual chunk refs: {0}")]
238 UnsupportedScheme(String),
239 #[error("error parsing bucket name from virtual ref URL {0}")]
240 CannotParseBucketName(String),
241 #[error(
242 "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"
243 )]
244 UnsupportedObjectKeyForBackend(String),
245 #[error("error fetching virtual reference")]
246 FetchError(#[source] Box<dyn std::error::Error + Send + Sync>),
247 #[error("the checksum of the object owning the virtual chunk has changed ({0})")]
248 ObjectModified(String),
249 #[error(
250 "error retrieving virtual chunk, not enough data. Expected: ({expected}), available ({available})"
251 )]
252 InvalidObjectSize { expected: u64, available: u64 },
253 #[error("azure store configuration must include an account")]
254 AzureConfigurationMustIncludeAccount,
255 #[error("decoding virtual chunk url")]
256 Decoding(#[from] FromUtf8Error),
257 #[error(
258 "no virtual chunk container named '{0}' found, check the repository configuration"
259 )]
260 NoContainerForName(String),
261 #[error("unknown error")]
262 OtherError(#[from] Box<dyn std::error::Error + Send + Sync>),
263}
264
265fn format_unauthorized_vcc(url_prefix: &str, name: &Option<String>) -> String {
266 let container = match name {
267 Some(n) => format!(" (container: {n})"),
268 None => String::new(),
269 };
270 format!(
271 "a virtual chunk in this repository resolves to the url prefix {url_prefix}{container}, \
272 to be able to fetch the chunk you need to authorize the virtual chunk container \
273 when you open/create the repository, see https://icechunk.io/en/stable/virtual/"
274 )
275}
276
277pub type VirtualReferenceError = ICError<VirtualReferenceErrorKind>;
278
279pub const VCC_RELATIVE_URL_SCHEME: &str = "vcc://";
280
281#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
282pub struct VirtualChunkLocation(String);
283
284impl VirtualChunkLocation {
285 pub fn url(&self) -> &str {
286 self.0.as_str()
287 }
288
289 fn from_trusted(s: String) -> Self {
291 VirtualChunkLocation(s)
292 }
293
294 pub fn is_relative(&self) -> bool {
296 self.0.starts_with(VCC_RELATIVE_URL_SCHEME)
297 }
298
299 pub fn parse_vcc(&self) -> Option<(&str, &str)> {
301 let rest = self.0.strip_prefix(VCC_RELATIVE_URL_SCHEME)?;
302 let slash = rest.find('/')?;
303 Some((&rest[..slash], &rest[slash + 1..]))
304 }
305
306 pub fn from_vcc_path(
308 container_name: &str,
309 relative_path: &str,
310 ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
311 if container_name.is_empty() || container_name.contains('/') {
312 return Err(VirtualReferenceError::capture(
313 VirtualReferenceErrorKind::NoContainerForName(container_name.to_string()),
314 ));
315 }
316 let mut result = String::with_capacity(
317 VCC_RELATIVE_URL_SCHEME.len()
318 + container_name.len()
319 + 1
320 + relative_path.len(),
321 );
322 result.push_str(VCC_RELATIVE_URL_SCHEME);
323 result.push_str(container_name);
324 for segment in relative_path.split('/').filter(|s| !s.is_empty()) {
325 result.push('/');
326 result.push_str(segment);
327 }
328 Ok(VirtualChunkLocation(result))
329 }
330
331 pub fn from_url(path: &str) -> Result<VirtualChunkLocation, VirtualReferenceError> {
350 Self::from_absolute_path(path)
353 }
354
355 fn from_absolute_path(
356 path: &str,
357 ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
358 match path.split_once("://") {
359 Some((scheme, after_scheme)) if !scheme.eq_ignore_ascii_case("file") => {
361 Self::from_verbatim_url(path, scheme, after_scheme)
362 }
363 _ => Self::from_normalized_url(path),
369 }
370 }
371
372 fn from_verbatim_url(
374 path: &str,
375 scheme: &str,
376 after_scheme: &str,
377 ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
378 let authority_len =
381 after_scheme.find(['/', '?', '#']).unwrap_or(after_scheme.len());
382 if authority_len == 0 {
383 let kind = if scheme.eq_ignore_ascii_case("vcc") {
384 VirtualReferenceErrorKind::NoContainerForName(path.to_string())
385 } else {
386 VirtualReferenceErrorKind::CannotParseBucketName(path.to_string())
387 };
388 return Err(VirtualReferenceError::capture(kind));
389 }
390
391 let mut location = path.to_string();
393 location[..scheme.len()].make_ascii_lowercase();
394 Ok(VirtualChunkLocation(location))
395 }
396
397 fn from_normalized_url(
399 path: &str,
400 ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
401 let url = url::Url::parse(path)
402 .map_err(|e| VirtualReferenceErrorKind::CannotParseUrl {
403 cause: e,
404 url: path.to_string(),
405 })
406 .capture()?;
407 let scheme = url.scheme();
408 url.path_segments()
411 .ok_or_else(|| VirtualReferenceErrorKind::NoPathSegments(path.into()))
412 .capture()?;
413
414 if url.host_str().is_none() {
415 match scheme {
416 "file" => {}
417 "vcc" => {
418 return Err(VirtualReferenceError::capture(
419 VirtualReferenceErrorKind::NoContainerForName(path.into()),
420 ));
421 }
422 _ => {
423 return Err(VirtualReferenceError::capture(
424 VirtualReferenceErrorKind::CannotParseBucketName(path.into()),
425 ));
426 }
427 }
428 }
429
430 Ok(VirtualChunkLocation(url.as_str().to_string()))
431 }
432}
433
434#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
435pub struct SecondsSinceEpoch(pub u32);
436
437#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
438pub enum Checksum {
439 LastModified(SecondsSinceEpoch),
440 ETag(ETag),
441}
442
443#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
444pub struct VirtualChunkRef {
445 pub location: VirtualChunkLocation,
446 pub offset: ChunkOffset,
447 pub length: ChunkLength,
448 pub checksum: Option<Checksum>,
449}
450
451#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
452pub struct ChunkRef {
453 pub id: ChunkId,
454 pub offset: ChunkOffset,
455 pub length: ChunkLength,
456}
457
458#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
459#[non_exhaustive]
460pub enum ChunkPayload {
461 Inline(Bytes),
462 Virtual(VirtualChunkRef),
463 Ref(ChunkRef),
464}
465
466impl ChunkPayload {
467 pub fn length(&self) -> ChunkLength {
469 match self {
470 ChunkPayload::Inline(bytes) => bytes.len() as ChunkLength,
471 ChunkPayload::Virtual(VirtualChunkRef { length, .. }) => *length,
472 ChunkPayload::Ref(ChunkRef { length, .. }) => *length,
473 }
474 }
475}
476
477#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
478pub struct ChunkInfo {
479 pub node: NodeId,
480 pub coord: ChunkIndices,
481 pub payload: ChunkPayload,
482}
483
484const COMPRESSION_ALG_NONE: u8 = 0;
485const COMPRESSION_ALG_ZSTD_DICT: u8 = 1;
486const MAX_DECOMPRESSED_LOCATION_SIZE: usize = 1_024;
488
489#[derive(PartialEq)]
490pub struct Manifest {
491 buffer: Vec<u8>,
492}
493
494impl std::fmt::Debug for Manifest {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 f.debug_struct("Manifest")
497 .field("id", &self.id())
498 .field("chunks", &self.len())
499 .finish_non_exhaustive()
500 }
501}
502
503impl Manifest {
504 pub fn id(&self) -> ManifestId {
505 ManifestId::new(self.root().id().0)
506 }
507
508 pub fn bytes(&self) -> &[u8] {
509 self.buffer.as_slice()
510 }
511
512 pub fn verify_buffer(buffer: &[u8]) -> Result<(), IcechunkFormatError> {
514 let _ =
515 flatbuffers::root_with_opts::<generated::Manifest<'_>>(&ROOT_OPTIONS, buffer)
516 .capture()?;
517 Ok(())
518 }
519
520 pub fn from_buffer(buffer: Vec<u8>) -> Result<Manifest, IcechunkFormatError> {
521 Self::verify_buffer(buffer.as_slice())?;
522 Ok(Manifest { buffer })
523 }
524
525 fn decompressor(
527 &self,
528 ) -> Result<Option<zstd::bulk::Decompressor<'static>>, IcechunkFormatError> {
529 let root = self.root();
530 if root.compression_algorithm() != COMPRESSION_ALG_ZSTD_DICT {
531 return Ok(None);
532 }
533 match root.location_dictionary() {
534 Some(dict_bytes) => {
535 let decompressor =
536 zstd::bulk::Decompressor::with_dictionary(dict_bytes.bytes())
537 .capture()?;
538 Ok(Some(decompressor))
539 }
540 None => Ok(None),
541 }
542 }
543
544 pub fn from_sorted_vec(
545 manifest_id: &ManifestId,
546 sorted_chunks: Vec<ChunkInfo>,
547 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
548 ) -> IcechunkResult<Option<Self>> {
549 let location_compression_dict =
550 train_location_dictionary(&sorted_chunks, virtual_chunks_compression_config)?;
551 let compressed_locations =
553 match (&location_compression_dict, virtual_chunks_compression_config) {
554 (Some(d), Some(config)) => compress_locations(&sorted_chunks, d, config),
555 _ => vec![None; sorted_chunks.len()],
556 };
557
558 let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024);
561
562 let len = sorted_chunks.len();
563 let mut all = sorted_chunks.into_iter().zip(compressed_locations).peekable();
564
565 let mut array_manifests = Vec::with_capacity(1);
566 while let Some(current_node) = all.peek().map(|(chunk, _)| chunk.node.clone()) {
567 let mut refs = Vec::with_capacity(len);
569 while let Some((chunk, precompressed)) =
570 all.next_if(|(chunk, _)| chunk.node == current_node)
571 {
572 refs.push(mk_chunk_ref(&mut builder, chunk, precompressed));
573 }
574
575 let node_id = Some(generated::ObjectId8::new(¤t_node.0));
576 let refs = Some(builder.create_vector(refs.as_slice()));
577 let array_manifest = generated::ArrayManifest::create(
578 &mut builder,
579 &generated::ArrayManifestArgs {
580 node_id: node_id.as_ref(),
581 refs,
582 ..Default::default()
583 },
584 );
585 array_manifests.push(array_manifest);
586 }
587
588 if array_manifests.is_empty() {
589 return Ok(None);
591 }
592
593 let arrays = builder.create_vector(array_manifests.as_slice());
594 let bin_manifest_id = generated::ObjectId12::new(&manifest_id.0);
595
596 let (location_dictionary, compression_algorithm) =
597 if let Some(ref dict) = location_compression_dict {
598 (Some(builder.create_vector(dict.as_slice())), COMPRESSION_ALG_ZSTD_DICT)
599 } else {
600 (None, COMPRESSION_ALG_NONE)
601 };
602
603 let manifest = generated::Manifest::create(
604 &mut builder,
605 &generated::ManifestArgs {
606 id: Some(&bin_manifest_id),
607 arrays: Some(arrays),
608 location_dictionary,
609 compression_algorithm,
610 ..Default::default()
611 },
612 );
613
614 builder.finish(manifest, Some("Ichk"));
615 let (mut buffer, offset) = builder.collapse();
616 buffer.drain(0..offset);
617 buffer.shrink_to_fit();
618 Ok(Some(Manifest { buffer }))
619 }
620
621 pub async fn from_stream<E>(
622 manifest_id: &ManifestId,
623 stream: impl Stream<Item = Result<ChunkInfo, E>>,
624 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
625 ) -> Result<Option<Self>, E>
626 where
627 E: From<IcechunkFormatError>,
628 {
629 let mut all = stream.try_collect::<Vec<_>>().await?;
630 all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord)));
631 Ok(Self::from_sorted_vec(manifest_id, all, virtual_chunks_compression_config)?)
632 }
633
634 pub async fn from_iter<T: IntoIterator<Item = ChunkInfo>>(
636 manifest_id: &ManifestId,
637 iter: T,
638 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
639 ) -> IcechunkResult<Option<Self>> {
640 Self::from_stream(
641 manifest_id,
642 futures::stream::iter(iter.into_iter().map(Ok::<_, IcechunkFormatError>)),
643 virtual_chunks_compression_config,
644 )
645 .await
646 }
647
648 pub fn len(&self) -> usize {
649 self.root().arrays().iter().map(|am| am.refs().len()).sum()
650 }
651
652 #[must_use]
653 pub fn is_empty(&self) -> bool {
654 self.len() == 0
655 }
656
657 #[expect(unsafe_code)]
658 fn root(&self) -> generated::Manifest<'_> {
659 unsafe { flatbuffers::root_unchecked::<generated::Manifest<'_>>(&self.buffer) }
663 }
664
665 pub fn arrays(&self) -> impl Iterator<Item = NodeId> {
666 self.root().arrays().iter().map(|am| NodeId::from(am.node_id().0))
667 }
668
669 pub fn uses_location_compression(&self) -> bool {
670 self.root().compression_algorithm() != COMPRESSION_ALG_NONE
671 }
672
673 pub fn location_dictionary_size(&self) -> Option<usize> {
674 self.root().location_dictionary().map(|d| d.len())
675 }
676
677 pub fn num_compressed_refs(&self) -> usize {
678 self.root()
679 .arrays()
680 .iter()
681 .flat_map(|am| am.refs().iter())
682 .filter(|r| r.compressed_location().is_some())
683 .count()
684 }
685
686 pub fn get_chunk_payload(
687 &self,
688 node: &NodeId,
689 coord: &ChunkIndices,
690 ) -> IcechunkResult<ChunkPayload> {
691 let mut decompressor = self.decompressor()?;
692 let manifest = self.root();
693 let chunk_ref = lookup_node(manifest, node)
694 .and_then(|array_manifest| lookup_ref(array_manifest, coord))
695 .ok_or_else(|| IcechunkFormatErrorKind::ChunkCoordinatesNotFound {
696 coords: coord.clone(),
697 })
698 .capture()?;
699 ref_to_payload(chunk_ref, decompressor.as_mut())
700 }
701
702 pub fn iter(
703 self: Arc<Self>,
704 node: NodeId,
705 ) -> Result<
706 impl Iterator<Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>>,
707 IcechunkFormatError,
708 > {
709 PayloadIterator::new(self, node)
710 }
711
712 pub fn chunk_payloads(
713 &self,
714 ) -> Result<
715 impl Iterator<Item = Result<ChunkPayload, IcechunkFormatError>> + '_,
716 IcechunkFormatError,
717 > {
718 let mut decompressor = self.decompressor()?;
719 let refs: Vec<_> =
720 self.root().arrays().iter().flat_map(|am| am.refs().iter()).collect();
721 Ok(refs.into_iter().map(move |r| ref_to_payload(r, decompressor.as_mut())))
722 }
723}
724
725fn lookup_node<'a>(
726 manifest: generated::Manifest<'a>,
727 node: &NodeId,
728) -> Option<generated::ArrayManifest<'a>> {
729 manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id))
730}
731
732fn lookup_ref<'a>(
733 array_manifest: generated::ArrayManifest<'a>,
734 coord: &ChunkIndices,
735) -> Option<generated::ChunkRef<'a>> {
736 array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| {
737 chunk_ref.index().iter().cmp(coords.iter().copied())
738 })
739}
740
741pub struct PayloadIterator {
742 manifest: Arc<Manifest>,
743 node_id: NodeId,
744 last_ref_index: usize,
745 decompressor: Option<zstd::bulk::Decompressor<'static>>,
746}
747
748impl std::fmt::Debug for PayloadIterator {
749 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750 f.debug_struct("PayloadIterator")
751 .field("node_id", &self.node_id)
752 .field("last_ref_index", &self.last_ref_index)
753 .finish_non_exhaustive()
754 }
755}
756
757impl PayloadIterator {
758 fn new(
759 manifest: Arc<Manifest>,
760 node_id: NodeId,
761 ) -> Result<Self, IcechunkFormatError> {
762 let decompressor = manifest.decompressor()?;
763 Ok(Self { manifest, node_id, last_ref_index: 0, decompressor })
764 }
765}
766
767impl Iterator for PayloadIterator {
768 type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>;
769
770 fn next(&mut self) -> Option<Self::Item> {
771 let manifest = self.manifest.root();
772 lookup_node(manifest, &self.node_id).and_then(|array_manifest| {
773 let refs = array_manifest.refs();
774 if self.last_ref_index >= refs.len() {
775 return None;
776 }
777
778 let chunk_ref = refs.get(self.last_ref_index);
779 self.last_ref_index += 1;
780 Some(
781 ref_to_payload(chunk_ref, self.decompressor.as_mut())
782 .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)),
783 )
784 })
785 }
786}
787
788fn ref_to_payload(
789 chunk_ref: generated::ChunkRef<'_>,
790 decompressor: Option<&mut zstd::bulk::Decompressor<'static>>,
791) -> Result<ChunkPayload, IcechunkFormatError> {
792 if let Some(chunk_id) = chunk_ref.chunk_id() {
793 let id = ChunkId::new(chunk_id.0);
794 Ok(ChunkPayload::Ref(ChunkRef {
795 id,
796 offset: chunk_ref.offset(),
797 length: chunk_ref.length(),
798 }))
799 } else if let Some(compressed) = chunk_ref.compressed_location() {
800 let decompressor = decompressor
801 .ok_or(IcechunkFormatErrorKind::MissingLocationCompressionDictionary)
802 .capture()?;
803 let decompressed = decompressor
804 .decompress(compressed.bytes(), MAX_DECOMPRESSED_LOCATION_SIZE)
805 .capture()?;
806 let location_string = String::from_utf8(decompressed)
807 .map_err(|e| {
808 IcechunkFormatErrorKind::IO(std::io::Error::new(
809 std::io::ErrorKind::InvalidData,
810 e,
811 ))
812 })
813 .capture()?;
814 let location = VirtualChunkLocation::from_trusted(location_string);
815 Ok(ChunkPayload::Virtual(VirtualChunkRef {
816 location,
817 checksum: checksum(&chunk_ref),
818 offset: chunk_ref.offset(),
819 length: chunk_ref.length(),
820 }))
821 } else if let Some(location) = chunk_ref.location() {
822 let location = VirtualChunkLocation::from_trusted(location.to_string());
823 Ok(ChunkPayload::Virtual(VirtualChunkRef {
824 location,
825 checksum: checksum(&chunk_ref),
826 offset: chunk_ref.offset(),
827 length: chunk_ref.length(),
828 }))
829 } else if let Some(data) = chunk_ref.inline() {
830 Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
831 } else {
832 Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
833 flatbuffers::InvalidFlatbuffer::InconsistentUnion {
834 field: Cow::Borrowed("chunk_id+location+inline"),
835 field_type: Cow::Borrowed("invalid"),
836 error_trace: Default::default(),
837 },
838 ))
839 .capture()
840 }
841}
842
843fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
844 if let Some(etag) = payload.checksum_etag() {
845 Some(Checksum::ETag(ETag(etag.to_string())))
846 } else if payload.checksum_last_modified() > 0 {
847 Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
848 } else {
849 None
850 }
851}
852
853fn train_location_dictionary(
862 chunks: &[ChunkInfo],
863 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
864) -> IcechunkResult<Option<Vec<u8>>> {
865 let Some(config) = virtual_chunks_compression_config else {
866 return Ok(None);
867 };
868 let max_samples = config.dictionary_max_training_samples as usize;
869 let min_chunks = config.min_num_chunks as usize;
870 let max_dict_size = config.dictionary_max_size_bytes as usize;
871
872 let mut virtual_count: usize = 0;
873 let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
874 let mut rng: SmallRng = rand::make_rng();
875
876 if chunks.len() < min_chunks {
877 return Ok(None);
878 }
879
880 for chunk in chunks {
881 if let ChunkPayload::Virtual(vref) = &chunk.payload {
882 let loc = vref.location.url();
883 if virtual_count < max_samples {
884 reservoir.push(loc);
886 } else {
887 let j = rng.random_range(0..=virtual_count);
889 if j < max_samples {
890 reservoir[j] = loc;
891 }
892 }
893 virtual_count += 1;
894 }
895 }
896
897 if virtual_count < min_chunks {
898 return Ok(None);
899 }
900
901 let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
902
903 let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
905 if small_count >= sample_bytes.len() / 2 {
906 tracing::warn!(
907 "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
908 sample_bytes.len()
909 );
910 return Ok(None);
911 }
912
913 let mut sample_data: Vec<u8> =
914 sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
915 let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
916 let total_sample_size = sample_data.len();
917
918 if total_sample_size > 0 && total_sample_size < max_dict_size {
920 let repeats = (max_dict_size / total_sample_size) + 1;
921 let original_data = sample_data.clone();
922 let original_sizes = sample_sizes.clone();
923 for _ in 0..repeats {
924 sample_data.extend_from_slice(&original_data);
925 sample_sizes.extend_from_slice(&original_sizes);
926 }
927 }
928
929 Ok(Some(
930 zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
931 .capture()?,
932 ))
933}
934
935fn compress_locations(
940 chunks: &[ChunkInfo],
941 dict: &[u8],
942 config: &LocationCompressionConfig,
943) -> Vec<Option<Vec<u8>>> {
944 let compression_level = config.compression_level;
945
946 #[cfg(not(target_family = "wasm"))]
947 {
948 let num_threads =
949 std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
950 let slice_size = chunks.len().div_ceil(num_threads);
951
952 std::thread::scope(|s| {
953 let handles: Vec<_> = chunks
954 .chunks(slice_size)
955 .map(|slice| {
956 s.spawn(|| {
957 let mut comp = zstd::bulk::Compressor::with_dictionary(
958 compression_level,
959 dict,
960 )
961 .ok();
962 slice
963 .iter()
964 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
965 (ChunkPayload::Virtual(vref), Some(comp)) => {
966 comp.compress(vref.location.url().as_bytes()).ok()
967 }
968 _ => None,
969 })
970 .collect::<Vec<_>>()
971 })
972 })
973 .collect();
974
975 #[expect(clippy::expect_used)]
976 handles
977 .into_iter()
978 .flat_map(|h| {
979 h.join()
980 .expect("Cannot join threads compressing virtual chunk locations")
981 })
982 .collect()
983 })
984 }
985
986 #[cfg(target_family = "wasm")]
987 {
988 let mut comp =
989 zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
990 chunks
991 .iter()
992 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
993 (ChunkPayload::Virtual(vref), Some(comp)) => {
994 comp.compress(vref.location.url().as_bytes()).ok()
995 }
996 _ => None,
997 })
998 .collect()
999 }
1000}
1001
1002fn mk_chunk_ref<'bldr>(
1003 builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
1004 chunk: ChunkInfo,
1005 precompressed_location: Option<Vec<u8>>,
1006) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
1007 let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
1008 match chunk.payload {
1009 ChunkPayload::Inline(bytes) => {
1010 let bytes = builder.create_vector(bytes.as_ref());
1011 let args = generated::ChunkRefArgs {
1012 inline: Some(bytes),
1013 index,
1014 ..Default::default()
1015 };
1016 generated::ChunkRef::create(builder, &args)
1017 }
1018 ChunkPayload::Virtual(virtual_chunk_ref) => {
1019 let location_str = virtual_chunk_ref.location.0.as_str();
1020 let (location, compressed_location) =
1021 if let Some(compressed) = precompressed_location {
1022 (None, Some(builder.create_vector(&compressed)))
1023 } else {
1024 (Some(builder.create_string(location_str)), None)
1025 };
1026 let args = generated::ChunkRefArgs {
1027 index,
1028 location,
1029 compressed_location,
1030 offset: virtual_chunk_ref.offset,
1031 length: virtual_chunk_ref.length,
1032 checksum_etag: match &virtual_chunk_ref.checksum {
1033 Some(cs) => match cs {
1034 Checksum::LastModified(_) => None,
1035 Checksum::ETag(etag) => {
1036 Some(builder.create_string(etag.0.as_str()))
1037 }
1038 },
1039 None => None,
1040 },
1041 checksum_last_modified: match &virtual_chunk_ref.checksum {
1042 Some(cs) => match cs {
1043 Checksum::LastModified(seconds) => seconds.0,
1044 Checksum::ETag(_) => 0,
1045 },
1046 None => 0,
1047 },
1048 ..Default::default()
1049 };
1050 generated::ChunkRef::create(builder, &args)
1051 }
1052 ChunkPayload::Ref(chunk_ref) => {
1053 let id = generated::ObjectId12::new(&chunk_ref.id.0);
1054 let args = generated::ChunkRefArgs {
1055 index,
1056 offset: chunk_ref.offset,
1057 length: chunk_ref.length,
1058 chunk_id: Some(&id),
1059 ..Default::default()
1060 };
1061 generated::ChunkRef::create(builder, &args)
1062 }
1063 }
1064}
1065
1066static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1067 max_depth: 64,
1068 max_tables: 500_000_000,
1069 max_apparent_size: crate::serializers::MAX_APPARENT_SIZE_INFLATION
1075 .saturating_mul(flatbuffers::FLATBUFFERS_MAX_BUFFER_SIZE),
1076 ignore_missing_null_terminator: true,
1077};
1078
1079#[cfg(test)]
1081pub(crate) fn root_options() -> &'static VerifierOptions {
1082 &ROOT_OPTIONS
1083}
1084
1085#[cfg(test)]
1086#[expect(unused_qualifications)] mod tests {
1088 use super::*;
1089 use crate::roundtrip_serialization_tests;
1090 use crate::strategies::{
1091 ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1092 manifest_splits, shapes_and_dims,
1093 };
1094 use icechunk_macros::{self, tokio_test};
1095 use itertools::{all, multizip};
1096 use proptest::collection::vec;
1097 use proptest::prelude::*;
1098 use std::error::Error;
1099 use test_strategy::proptest as alt_proptest;
1100
1101 roundtrip_serialization_tests!(
1102 serialize_and_deserialize_manifest_ref - manifest_ref,
1103 serialize_and_deserialize_manifest_splits - manifest_splits
1104 );
1105
1106 #[alt_proptest]
1107 fn test_property_extents_set_ops_same(
1108 #[strategy(manifest_extents(4))] e: ManifestExtents,
1109 ) {
1110 prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1111 prop_assert_eq!(e.union(&e), e.clone());
1112 prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1113 }
1114
1115 #[alt_proptest]
1116 fn test_property_extents_set_ops(
1117 #[strategy(manifest_extents(4))] e1: ManifestExtents,
1118 #[strategy(manifest_extents(4))] e2: ManifestExtents,
1119 ) {
1120 let union = e1.union(&e2);
1121 let intersection = e1.intersection(&e2);
1122
1123 prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1124 prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1125 prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1126 prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1127
1128 prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1130 prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1131
1132 if intersection.is_some() {
1133 let int = intersection.unwrap();
1134 let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1135 prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1136 prop_assert_eq!(int.overlap_with(&e2), expected);
1137 } else {
1138 prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1139 prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1140 }
1141 }
1142
1143 #[alt_proptest]
1144 fn test_property_extents_widths(
1145 #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1146 #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1147 #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1148 ) {
1149 let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1150 let extent2 = ManifestExtents::from_ranges_iter(
1151 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1152 |(extent, dleft, dright)| {
1153 ((extent.start as i32 + dleft) as u32)
1154 ..((extent.end as i32 + dright) as u32)
1155 },
1156 ),
1157 );
1158
1159 if all(delta_left.iter(), |elem| elem == &0i32)
1160 && all(delta_right.iter(), |elem| elem == &0i32)
1161 {
1162 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1163 }
1164
1165 let extent2 = ManifestExtents::from_ranges_iter(
1166 multizip((
1167 extent1.iter(),
1168 widths.iter(),
1169 delta_left.iter(),
1170 delta_right.iter(),
1171 ))
1172 .map(|(extent, width, dleft, dright)| {
1173 let (low, high) = (dleft.min(dright), dleft.max(dright));
1174 ((extent.start as i32 + width + low) as u32)
1175 ..((extent.end as i32 + width + high) as u32)
1176 }),
1177 );
1178
1179 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1180
1181 let extent2 = ManifestExtents::from_ranges_iter(
1182 multizip((
1183 extent1.iter(),
1184 widths.iter(),
1185 delta_left.iter(),
1186 delta_right.iter(),
1187 ))
1188 .map(|(extent, width, dleft, dright)| {
1189 let (low, high) = (dleft.min(dright), dleft.max(dright));
1190 ((extent.start as i32 - width - high).max(0i32) as u32)
1191 ..((extent.end as i32 - width - low) as u32)
1192 }),
1193 );
1194
1195 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1196
1197 let extent2 = ManifestExtents::from_ranges_iter(
1198 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1199 |(extent, dleft, dright)| {
1200 ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1201 ..((extent.end as i32 + dright + 1) as u32)
1202 },
1203 ),
1204 );
1205 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1206 }
1207
1208 #[icechunk_macros::test]
1214 fn test_from_url_preserves_all_url_parts() -> Result<(), Box<dyn Error>> {
1215 let input = "https://user:pass@host.com:8443/a//b/../c.bin?versionId=42#frag";
1216 let stored = VirtualChunkLocation::from_url(input)?;
1217 assert_eq!(stored.url(), input);
1218 Ok(())
1219 }
1220
1221 #[icechunk_macros::test]
1224 fn test_from_url_object_store_keys_are_verbatim() -> Result<(), Box<dyn Error>> {
1225 for input in [
1226 "s3://foo/bar//../baz",
1227 "gcs://b/a//b",
1228 "s3://bucket/key.with.dots/../sibling",
1229 "az://container/a/./b//c",
1230 "tigris://bucket/x//y",
1231 "https://host/a//b/../c.bin?v=1#frag",
1232 ] {
1233 assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1234 }
1235 let with_parts = "s3://user:pass@host:9000/a//b/../c?x=1#f";
1237 assert_eq!(VirtualChunkLocation::from_url(with_parts)?.url(), with_parts);
1238 Ok(())
1239 }
1240
1241 #[icechunk_macros::test]
1244 fn test_from_url_vcc_is_verbatim() -> Result<(), Box<dyn Error>> {
1245 let input = "vcc://name/a//b/../c";
1246 assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1247 Ok(())
1248 }
1249
1250 #[icechunk_macros::test]
1253 fn test_from_url_file_is_normalized() -> Result<(), Box<dyn Error>> {
1254 assert_eq!(
1255 VirtualChunkLocation::from_url("file:///a/b/../c")?.url(),
1256 "file:///a/c"
1257 );
1258 assert_eq!(
1259 VirtualChunkLocation::from_url("file:///authorized/%2e%2e/secret")?.url(),
1260 "file:///secret"
1261 );
1262 Ok(())
1263 }
1264
1265 #[icechunk_macros::test]
1268 fn test_from_url_lowercases_scheme_only() -> Result<(), Box<dyn Error>> {
1269 assert_eq!(
1270 VirtualChunkLocation::from_url("S3://Bucket/Key//..")?.url(),
1271 "s3://Bucket/Key//.."
1272 );
1273 Ok(())
1274 }
1275
1276 #[icechunk_macros::test]
1277 fn test_from_url_rejects_bad_input() {
1278 use VirtualReferenceErrorKind::*;
1279 let kind =
1280 |s: &str| VirtualChunkLocation::from_url(s).map(|_| ()).unwrap_err().kind;
1281
1282 assert!(matches!(kind("not-a-url"), CannotParseUrl { .. }));
1284 assert!(matches!(kind(""), CannotParseUrl { .. }));
1285 assert!(matches!(kind("mailto:foo"), NoPathSegments(_)));
1287 assert!(matches!(kind("s3:///key"), CannotParseBucketName(_)));
1289 assert!(matches!(kind("vcc:///rel"), NoContainerForName(_)));
1290 assert!(matches!(kind("s3:/bucket/key"), CannotParseBucketName(_)));
1293 }
1294
1295 #[icechunk_macros::test]
1298 fn test_from_url_file_single_slash_accepted() -> Result<(), Box<dyn Error>> {
1299 assert_eq!(VirtualChunkLocation::from_url("file:/x")?.url(), "file:///x");
1300 Ok(())
1301 }
1302
1303 #[icechunk_macros::test]
1304 fn test_overlaps() -> Result<(), Box<dyn Error>> {
1305 let e1 = ManifestExtents::new(
1306 vec![0u32, 1, 2].as_slice(),
1307 vec![2u32, 4, 6].as_slice(),
1308 );
1309
1310 let e2 = ManifestExtents::new(
1311 vec![10u32, 1, 2].as_slice(),
1312 vec![12u32, 4, 6].as_slice(),
1313 );
1314
1315 let union = ManifestExtents::new(
1316 vec![0u32, 1, 2].as_slice(),
1317 vec![12u32, 4, 6].as_slice(),
1318 );
1319
1320 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1321 assert_eq!(e1.intersection(&e2), None);
1322 assert_eq!(e1.union(&e2), union);
1323
1324 let e1 = ManifestExtents::new(
1325 vec![0u32, 1, 2].as_slice(),
1326 vec![2u32, 4, 6].as_slice(),
1327 );
1328 let e2 = ManifestExtents::new(
1329 vec![2u32, 1, 2].as_slice(),
1330 vec![42u32, 4, 6].as_slice(),
1331 );
1332 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1333 assert_eq!(e1.overlap_with(&e2), Overlap::None);
1334
1335 let e1 = ManifestExtents::new(
1337 vec![0u32, 1, 2].as_slice(),
1338 vec![3u32, 4, 6].as_slice(),
1339 );
1340 let e2 = ManifestExtents::new(
1341 vec![2u32, 1, 2].as_slice(),
1342 vec![3u32, 4, 6].as_slice(),
1343 );
1344 let union = ManifestExtents::new(
1345 vec![0u32, 1, 2].as_slice(),
1346 vec![3u32, 4, 6].as_slice(),
1347 );
1348 let intersection = ManifestExtents::new(
1349 vec![2u32, 1, 2].as_slice(),
1350 vec![3u32, 4, 6].as_slice(),
1351 );
1352 assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1353 assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1354 assert_eq!(e1.union(&e2), union.clone());
1355 assert_eq!(e2.union(&e1), union.clone());
1356 assert_eq!(e1.intersection(&e2), Some(intersection));
1357
1358 let e1 = ManifestExtents::new(
1360 vec![0u32, 1, 2].as_slice(),
1361 vec![3u32, 4, 6].as_slice(),
1362 );
1363 let e2 = ManifestExtents::new(
1364 vec![2u32, 1, 2].as_slice(),
1365 vec![2u32, 4, 6].as_slice(),
1366 );
1367 assert_eq!(e1.intersection(&e2), None);
1368
1369 let splits = ManifestSplits::from_edges(vec![
1371 vec![0, 10, 20],
1372 vec![0, 1, 2],
1373 vec![0, 21, 22],
1374 ]);
1375 for vec in splits.iter().combinations(2) {
1376 assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1377 assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1378 }
1379
1380 Ok(())
1381 }
1382
1383 #[alt_proptest]
1384 fn test_manifest_split_from_edges(
1385 #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1386 ) {
1387 let ShapeDim { shape, .. } = shape_dim;
1389
1390 let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1391 let split_shape: Vec<u64> = shape
1392 .iter()
1393 .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1394 .collect();
1395
1396 let ndim = shape.len();
1397 let edges: Vec<Vec<u32>> = (0usize..ndim)
1398 .map(|axis| {
1399 uniform_manifest_split_edges(
1400 num_chunks[axis],
1401 &(split_shape[axis] as u32),
1402 )
1403 })
1404 .collect();
1405
1406 let splits = ManifestSplits::from_edges(edges.into_iter());
1407 for edge in splits.iter() {
1408 prop_assert_eq!(edge.len(), ndim);
1410 for range in edge.iter() {
1411 prop_assert!(range.end > range.start);
1412 }
1413 }
1414
1415 for edges in splits.iter().combinations(2) {
1417 let is_equal =
1418 zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1419 (range1.start == range2.start) && (range1.end == range2.end)
1420 });
1421 prop_assert!(!is_equal);
1422 }
1423 }
1424
1425 const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1426 min_num_chunks: 10,
1427 dictionary_max_training_samples: 500,
1428 dictionary_max_size_bytes: 16 * 1024,
1429 compression_level: 3,
1430 };
1431
1432 fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1433 let node = NodeId::random();
1434 (0..n)
1435 .map(|i| ChunkInfo {
1436 node: node.clone(),
1437 coord: ChunkIndices(vec![i as u32]),
1438 payload: ChunkPayload::Virtual(VirtualChunkRef {
1439 location: VirtualChunkLocation::from_url(&format!(
1440 "s3://my-bucket/path/to/data/chunk_{i:06}"
1441 ))
1442 .unwrap(),
1443 offset: i as u64 * 1024,
1444 length: 1024,
1445 checksum: None,
1446 }),
1447 })
1448 .collect()
1449 }
1450
1451 #[tokio_test]
1452 async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1453 let chunks = make_virtual_chunks(50);
1455 let manifest = Manifest::from_iter(
1456 &ManifestId::random(),
1457 chunks.clone(),
1458 Some(&COMPRESS_CONFIG),
1459 )
1460 .await?
1461 .unwrap();
1462
1463 let root = manifest.root();
1464 assert!(root.location_dictionary().is_some());
1465 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1466 for am in root.arrays().iter() {
1467 for r in am.refs().iter() {
1468 assert!(r.compressed_location().is_some());
1469 assert!(r.location().is_none());
1470 }
1471 }
1472
1473 for chunk in &chunks {
1474 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1475 assert_eq!(payload, chunk.payload);
1476 }
1477 Ok(())
1478 }
1479
1480 #[tokio_test]
1481 async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1482 let chunks = make_virtual_chunks(5);
1484 let manifest = Manifest::from_iter(
1485 &ManifestId::random(),
1486 chunks.clone(),
1487 Some(&COMPRESS_CONFIG),
1488 )
1489 .await?
1490 .unwrap();
1491
1492 let root = manifest.root();
1493 assert!(root.location_dictionary().is_none());
1494 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1495 for am in root.arrays().iter() {
1496 for r in am.refs().iter() {
1497 assert!(r.compressed_location().is_none());
1498 assert!(r.location().is_some());
1499 }
1500 }
1501
1502 for chunk in &chunks {
1503 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1504 assert_eq!(payload, chunk.payload);
1505 }
1506 Ok(())
1507 }
1508
1509 #[tokio_test]
1510 async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1511 let node = NodeId::random();
1513 let mut chunks: Vec<ChunkInfo> = (0..30)
1514 .map(|i| ChunkInfo {
1515 node: node.clone(),
1516 coord: ChunkIndices(vec![i]),
1517 payload: ChunkPayload::Virtual(VirtualChunkRef {
1518 location: VirtualChunkLocation::from_url(&format!(
1519 "s3://my-bucket/path/to/data/chunk_{i:06}"
1520 ))
1521 .unwrap(),
1522 offset: i as u64 * 1024,
1523 length: 1024,
1524 checksum: None,
1525 }),
1526 })
1527 .collect();
1528 chunks.push(ChunkInfo {
1529 node: node.clone(),
1530 coord: ChunkIndices(vec![100]),
1531 payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1532 });
1533 chunks.push(ChunkInfo {
1534 node: node.clone(),
1535 coord: ChunkIndices(vec![101]),
1536 payload: ChunkPayload::Ref(ChunkRef {
1537 id: ChunkId::random(),
1538 offset: 0,
1539 length: 512,
1540 }),
1541 });
1542
1543 let manifest = Manifest::from_iter(
1544 &ManifestId::random(),
1545 chunks.clone(),
1546 Some(&COMPRESS_CONFIG),
1547 )
1548 .await?
1549 .unwrap();
1550
1551 for chunk in &chunks {
1552 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1553 assert_eq!(payload, chunk.payload);
1554 }
1555 Ok(())
1556 }
1557
1558 #[tokio_test]
1559 async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1560 let chunks = make_virtual_chunks(50);
1562 let manifest = Manifest::from_iter(
1563 &ManifestId::random(),
1564 chunks.clone(),
1565 Some(&COMPRESS_CONFIG),
1566 )
1567 .await?
1568 .unwrap();
1569 let bytes = manifest.bytes().to_vec();
1570 let manifest2 = Manifest::from_buffer(bytes)?;
1571
1572 for chunk in &chunks {
1573 let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1574 assert_eq!(payload, chunk.payload);
1575 }
1576 Ok(())
1577 }
1578
1579 #[tokio_test]
1580 async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1581 let chunks = make_virtual_chunks(50);
1583 let node = chunks[0].node.clone();
1584 let manifest = Arc::new(
1585 Manifest::from_iter(
1586 &ManifestId::random(),
1587 chunks.clone(),
1588 Some(&COMPRESS_CONFIG),
1589 )
1590 .await?
1591 .unwrap(),
1592 );
1593
1594 let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1595 assert_eq!(iter_results.len(), 50);
1596 for (i, result) in iter_results.into_iter().enumerate() {
1597 let (coord, payload) = result?;
1598 assert_eq!(coord, chunks[i].coord);
1599 assert_eq!(payload, chunks[i].payload);
1600 }
1601 Ok(())
1602 }
1603
1604 #[tokio_test]
1605 async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1606 let chunks = make_virtual_chunks(50);
1608 let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1609 .await?
1610 .unwrap();
1611
1612 let root = manifest.root();
1613 assert!(root.location_dictionary().is_none());
1614 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1615
1616 for am in root.arrays().iter() {
1618 for r in am.refs().iter() {
1619 if r.chunk_id().is_none() && r.inline().is_none() {
1620 assert!(r.location().is_some(), "location should be set");
1621 assert!(
1622 r.compressed_location().is_none(),
1623 "compressed_location should be None"
1624 );
1625 }
1626 }
1627 }
1628
1629 for chunk in &chunks {
1631 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1632 assert_eq!(payload, chunk.payload);
1633 }
1634 Ok(())
1635 }
1636
1637 #[tokio_test]
1638 async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1639 let chunks = make_virtual_chunks(50);
1641 let manifest = Manifest::from_iter(
1642 &ManifestId::random(),
1643 chunks.clone(),
1644 Some(&COMPRESS_CONFIG),
1645 )
1646 .await?
1647 .unwrap();
1648
1649 let root = manifest.root();
1650 assert!(root.location_dictionary().is_some());
1651 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1652
1653 for am in root.arrays().iter() {
1655 for r in am.refs().iter() {
1656 if r.chunk_id().is_none() && r.inline().is_none() {
1657 assert!(
1658 r.compressed_location().is_some(),
1659 "compressed_location should be set"
1660 );
1661 assert!(
1662 r.location().is_none(),
1663 "location should be None for compressed chunks"
1664 );
1665 }
1666 }
1667 }
1668
1669 for chunk in &chunks {
1671 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1672 assert_eq!(payload, chunk.payload);
1673 }
1674 Ok(())
1675 }
1676
1677 #[tokio_test]
1678 async fn test_compression_skipped_when_locations_too_short()
1679 -> Result<(), Box<dyn Error>> {
1680 let config = LocationCompressionConfig {
1684 min_num_chunks: 2,
1685 dictionary_max_training_samples: 500,
1686 dictionary_max_size_bytes: 256,
1687 compression_level: 3,
1688 };
1689
1690 let node = NodeId::random();
1691 let short_chunks: Vec<ChunkInfo> = (0..2)
1693 .map(|i| ChunkInfo {
1694 node: node.clone(),
1695 coord: ChunkIndices(vec![i as u32]),
1696 payload: ChunkPayload::Virtual(VirtualChunkRef {
1697 location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1698 offset: i as u64 * 1024,
1699 length: 1024,
1700 checksum: None,
1701 }),
1702 })
1703 .collect();
1704
1705 let manifest = Manifest::from_iter(
1706 &ManifestId::random(),
1707 short_chunks.clone(),
1708 Some(&config),
1709 )
1710 .await?
1711 .unwrap();
1712
1713 let root = manifest.root();
1714 assert!(root.location_dictionary().is_none());
1715 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1716 for am in root.arrays().iter() {
1717 for r in am.refs().iter() {
1718 assert!(r.compressed_location().is_none());
1719 assert!(r.location().is_some());
1720 }
1721 }
1722
1723 for chunk in &short_chunks {
1724 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1725 assert_eq!(payload, chunk.payload);
1726 }
1727
1728 let long_chunks: Vec<ChunkInfo> = (0..2)
1730 .map(|i| ChunkInfo {
1731 node: node.clone(),
1732 coord: ChunkIndices(vec![i as u32]),
1733 payload: ChunkPayload::Virtual(VirtualChunkRef {
1734 location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1735 offset: i as u64 * 1024,
1736 length: 1024,
1737 checksum: None,
1738 }),
1739 })
1740 .collect();
1741
1742 let manifest = Manifest::from_iter(
1743 &ManifestId::random(),
1744 long_chunks.clone(),
1745 Some(&config),
1746 )
1747 .await?
1748 .unwrap();
1749
1750 let root = manifest.root();
1751 assert!(root.location_dictionary().is_some());
1752 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1753 for am in root.arrays().iter() {
1754 for r in am.refs().iter() {
1755 assert!(r.compressed_location().is_some());
1756 assert!(r.location().is_none());
1757 }
1758 }
1759
1760 for chunk in &long_chunks {
1761 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1762 assert_eq!(payload, chunk.payload);
1763 }
1764 Ok(())
1765 }
1766
1767 #[tokio_test]
1768 async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1769 let node = NodeId::random();
1772 let chunks: Vec<ChunkInfo> = (0..100)
1773 .map(|i| ChunkInfo {
1774 node: node.clone(),
1775 coord: ChunkIndices(vec![i as u32]),
1776 payload: ChunkPayload::Virtual(VirtualChunkRef {
1777 location: VirtualChunkLocation::from_url(&format!(
1778 "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"
1779 ))
1780 .unwrap(),
1781 offset: i as u64 * 4096,
1782 length: 4096,
1783 checksum: None,
1784 }),
1785 })
1786 .collect();
1787
1788 let manifest_uncompressed =
1790 Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1791 .await?
1792 .unwrap();
1793 let size_uncompressed = manifest_uncompressed.bytes().len();
1794
1795 assert!(!manifest_uncompressed.uses_location_compression());
1796 assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1797
1798 let manifest_compressed = Manifest::from_iter(
1800 &ManifestId::random(),
1801 chunks.clone(),
1802 Some(&COMPRESS_CONFIG),
1803 )
1804 .await?
1805 .unwrap();
1806 let size_compressed = manifest_compressed.bytes().len();
1807
1808 assert!(manifest_compressed.uses_location_compression());
1809 assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1810 assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1811
1812 let root = manifest_compressed.root();
1814 for am in root.arrays().iter() {
1815 for r in am.refs().iter() {
1816 let compressed = r.compressed_location().unwrap();
1817 assert!(
1818 compressed.len() < 40,
1819 "compressed_location ({} bytes) should be under 40 bytes \
1820 (raw location is 118 bytes)",
1821 compressed.len(),
1822 );
1823 }
1824 }
1825
1826 assert!(
1828 size_compressed < size_uncompressed,
1829 "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1830 );
1831
1832 let saving_pct =
1833 (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1834 assert!(
1835 saving_pct > 15.0,
1836 "expected at least 15% size reduction with redundant locations, \
1837 got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1838 );
1839
1840 for chunk in &chunks {
1842 let p1 = manifest_uncompressed
1843 .get_chunk_payload(&chunk.node, &chunk.coord)
1844 .unwrap();
1845 let p2 =
1846 manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1847 assert_eq!(p1, chunk.payload);
1848 assert_eq!(p2, chunk.payload);
1849 }
1850
1851 Ok(())
1852 }
1853
1854 #[tokio_test]
1855 async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1856 let chunks = make_virtual_chunks(50);
1858 let manifest = Manifest::from_iter(
1859 &ManifestId::random(),
1860 chunks.clone(),
1861 Some(&COMPRESS_CONFIG),
1862 )
1863 .await?
1864 .unwrap();
1865
1866 let payloads: Vec<_> =
1867 manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1868 assert_eq!(payloads.len(), 50);
1869 for (i, payload) in payloads.iter().enumerate() {
1870 assert_eq!(payload, &chunks[i].payload);
1871 }
1872 Ok(())
1873 }
1874}