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 mut buffer = [0u8; MAX_DECOMPRESSED_LOCATION_SIZE];
805 let len = decompressor
806 .decompress_to_buffer(compressed.bytes(), &mut buffer)
807 .capture()?;
808 let location_string = std::str::from_utf8(&buffer[..len])
809 .map_err(|e| {
810 IcechunkFormatErrorKind::IO(std::io::Error::new(
811 std::io::ErrorKind::InvalidData,
812 e,
813 ))
814 })
815 .capture()?
816 .to_owned();
817 let location = VirtualChunkLocation::from_trusted(location_string);
818 Ok(ChunkPayload::Virtual(VirtualChunkRef {
819 location,
820 checksum: checksum(&chunk_ref),
821 offset: chunk_ref.offset(),
822 length: chunk_ref.length(),
823 }))
824 } else if let Some(location) = chunk_ref.location() {
825 let location = VirtualChunkLocation::from_trusted(location.to_string());
826 Ok(ChunkPayload::Virtual(VirtualChunkRef {
827 location,
828 checksum: checksum(&chunk_ref),
829 offset: chunk_ref.offset(),
830 length: chunk_ref.length(),
831 }))
832 } else if let Some(data) = chunk_ref.inline() {
833 Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
834 } else {
835 Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
836 flatbuffers::InvalidFlatbuffer::InconsistentUnion {
837 field: Cow::Borrowed("chunk_id+location+inline"),
838 field_type: Cow::Borrowed("invalid"),
839 error_trace: Default::default(),
840 },
841 ))
842 .capture()
843 }
844}
845
846fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
847 if let Some(etag) = payload.checksum_etag() {
848 Some(Checksum::ETag(ETag(etag.to_string())))
849 } else if payload.checksum_last_modified() > 0 {
850 Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
851 } else {
852 None
853 }
854}
855
856fn train_location_dictionary(
865 chunks: &[ChunkInfo],
866 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
867) -> IcechunkResult<Option<Vec<u8>>> {
868 let Some(config) = virtual_chunks_compression_config else {
869 return Ok(None);
870 };
871 let max_samples = config.dictionary_max_training_samples as usize;
872 let min_chunks = config.min_num_chunks as usize;
873 let max_dict_size = config.dictionary_max_size_bytes as usize;
874
875 let mut virtual_count: usize = 0;
876 let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
877 let mut rng: SmallRng = rand::make_rng();
878
879 if chunks.len() < min_chunks {
880 return Ok(None);
881 }
882
883 for chunk in chunks {
884 if let ChunkPayload::Virtual(vref) = &chunk.payload {
885 let loc = vref.location.url();
886 if virtual_count < max_samples {
887 reservoir.push(loc);
889 } else {
890 let j = rng.random_range(0..=virtual_count);
892 if j < max_samples {
893 reservoir[j] = loc;
894 }
895 }
896 virtual_count += 1;
897 }
898 }
899
900 if virtual_count < min_chunks {
901 return Ok(None);
902 }
903
904 let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
905
906 let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
908 if small_count >= sample_bytes.len() / 2 {
909 tracing::warn!(
910 "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
911 sample_bytes.len()
912 );
913 return Ok(None);
914 }
915
916 let mut sample_data: Vec<u8> =
917 sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
918 let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
919 let total_sample_size = sample_data.len();
920
921 if total_sample_size > 0 && total_sample_size < max_dict_size {
923 let repeats = (max_dict_size / total_sample_size) + 1;
924 let original_data = sample_data.clone();
925 let original_sizes = sample_sizes.clone();
926 for _ in 0..repeats {
927 sample_data.extend_from_slice(&original_data);
928 sample_sizes.extend_from_slice(&original_sizes);
929 }
930 }
931
932 Ok(Some(
933 zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
934 .capture()?,
935 ))
936}
937
938fn compress_locations(
943 chunks: &[ChunkInfo],
944 dict: &[u8],
945 config: &LocationCompressionConfig,
946) -> Vec<Option<Vec<u8>>> {
947 let compression_level = config.compression_level;
948
949 #[cfg(not(target_family = "wasm"))]
950 {
951 let num_threads =
952 std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
953 let slice_size = chunks.len().div_ceil(num_threads);
954
955 std::thread::scope(|s| {
956 let handles: Vec<_> = chunks
957 .chunks(slice_size)
958 .map(|slice| {
959 s.spawn(|| {
960 let mut comp = zstd::bulk::Compressor::with_dictionary(
961 compression_level,
962 dict,
963 )
964 .ok();
965 slice
966 .iter()
967 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
968 (ChunkPayload::Virtual(vref), Some(comp)) => {
969 comp.compress(vref.location.url().as_bytes()).ok()
970 }
971 _ => None,
972 })
973 .collect::<Vec<_>>()
974 })
975 })
976 .collect();
977
978 #[expect(clippy::expect_used)]
979 handles
980 .into_iter()
981 .flat_map(|h| {
982 h.join()
983 .expect("Cannot join threads compressing virtual chunk locations")
984 })
985 .collect()
986 })
987 }
988
989 #[cfg(target_family = "wasm")]
990 {
991 let mut comp =
992 zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
993 chunks
994 .iter()
995 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
996 (ChunkPayload::Virtual(vref), Some(comp)) => {
997 comp.compress(vref.location.url().as_bytes()).ok()
998 }
999 _ => None,
1000 })
1001 .collect()
1002 }
1003}
1004
1005fn mk_chunk_ref<'bldr>(
1006 builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
1007 chunk: ChunkInfo,
1008 precompressed_location: Option<Vec<u8>>,
1009) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
1010 let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
1011 match chunk.payload {
1012 ChunkPayload::Inline(bytes) => {
1013 let bytes = builder.create_vector(bytes.as_ref());
1014 let args = generated::ChunkRefArgs {
1015 inline: Some(bytes),
1016 index,
1017 ..Default::default()
1018 };
1019 generated::ChunkRef::create(builder, &args)
1020 }
1021 ChunkPayload::Virtual(virtual_chunk_ref) => {
1022 let location_str = virtual_chunk_ref.location.0.as_str();
1023 let (location, compressed_location) =
1024 if let Some(compressed) = precompressed_location {
1025 (None, Some(builder.create_vector(&compressed)))
1026 } else {
1027 (Some(builder.create_string(location_str)), None)
1028 };
1029 let args = generated::ChunkRefArgs {
1030 index,
1031 location,
1032 compressed_location,
1033 offset: virtual_chunk_ref.offset,
1034 length: virtual_chunk_ref.length,
1035 checksum_etag: match &virtual_chunk_ref.checksum {
1036 Some(cs) => match cs {
1037 Checksum::LastModified(_) => None,
1038 Checksum::ETag(etag) => {
1039 Some(builder.create_string(etag.0.as_str()))
1040 }
1041 },
1042 None => None,
1043 },
1044 checksum_last_modified: match &virtual_chunk_ref.checksum {
1045 Some(cs) => match cs {
1046 Checksum::LastModified(seconds) => seconds.0,
1047 Checksum::ETag(_) => 0,
1048 },
1049 None => 0,
1050 },
1051 ..Default::default()
1052 };
1053 generated::ChunkRef::create(builder, &args)
1054 }
1055 ChunkPayload::Ref(chunk_ref) => {
1056 let id = generated::ObjectId12::new(&chunk_ref.id.0);
1057 let args = generated::ChunkRefArgs {
1058 index,
1059 offset: chunk_ref.offset,
1060 length: chunk_ref.length,
1061 chunk_id: Some(&id),
1062 ..Default::default()
1063 };
1064 generated::ChunkRef::create(builder, &args)
1065 }
1066 }
1067}
1068
1069static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1070 max_depth: 64,
1071 max_tables: 500_000_000,
1072 max_apparent_size: crate::serializers::MAX_APPARENT_SIZE_INFLATION
1078 .saturating_mul(flatbuffers::FLATBUFFERS_MAX_BUFFER_SIZE),
1079 ignore_missing_null_terminator: true,
1080};
1081
1082#[cfg(test)]
1084pub(crate) fn root_options() -> &'static VerifierOptions {
1085 &ROOT_OPTIONS
1086}
1087
1088#[cfg(test)]
1089#[expect(unused_qualifications)] mod tests {
1091 use super::*;
1092 use crate::roundtrip_serialization_tests;
1093 use crate::strategies::{
1094 ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1095 manifest_splits, shapes_and_dims,
1096 };
1097 use icechunk_macros::{self, tokio_test};
1098 use itertools::{all, multizip};
1099 use proptest::collection::vec;
1100 use proptest::prelude::*;
1101 use std::error::Error;
1102 use test_strategy::proptest as alt_proptest;
1103
1104 roundtrip_serialization_tests!(
1105 serialize_and_deserialize_manifest_ref - manifest_ref,
1106 serialize_and_deserialize_manifest_splits - manifest_splits
1107 );
1108
1109 #[alt_proptest]
1110 fn test_property_extents_set_ops_same(
1111 #[strategy(manifest_extents(4))] e: ManifestExtents,
1112 ) {
1113 prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1114 prop_assert_eq!(e.union(&e), e.clone());
1115 prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1116 }
1117
1118 #[alt_proptest]
1119 fn test_property_extents_set_ops(
1120 #[strategy(manifest_extents(4))] e1: ManifestExtents,
1121 #[strategy(manifest_extents(4))] e2: ManifestExtents,
1122 ) {
1123 let union = e1.union(&e2);
1124 let intersection = e1.intersection(&e2);
1125
1126 prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1127 prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1128 prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1129 prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1130
1131 prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1133 prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1134
1135 if intersection.is_some() {
1136 let int = intersection.unwrap();
1137 let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1138 prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1139 prop_assert_eq!(int.overlap_with(&e2), expected);
1140 } else {
1141 prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1142 prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1143 }
1144 }
1145
1146 #[alt_proptest]
1147 fn test_property_extents_widths(
1148 #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1149 #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1150 #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1151 ) {
1152 let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1153 let extent2 = ManifestExtents::from_ranges_iter(
1154 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1155 |(extent, dleft, dright)| {
1156 ((extent.start as i32 + dleft) as u32)
1157 ..((extent.end as i32 + dright) as u32)
1158 },
1159 ),
1160 );
1161
1162 if all(delta_left.iter(), |elem| elem == &0i32)
1163 && all(delta_right.iter(), |elem| elem == &0i32)
1164 {
1165 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1166 }
1167
1168 let extent2 = ManifestExtents::from_ranges_iter(
1169 multizip((
1170 extent1.iter(),
1171 widths.iter(),
1172 delta_left.iter(),
1173 delta_right.iter(),
1174 ))
1175 .map(|(extent, width, dleft, dright)| {
1176 let (low, high) = (dleft.min(dright), dleft.max(dright));
1177 ((extent.start as i32 + width + low) as u32)
1178 ..((extent.end as i32 + width + high) as u32)
1179 }),
1180 );
1181
1182 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1183
1184 let extent2 = ManifestExtents::from_ranges_iter(
1185 multizip((
1186 extent1.iter(),
1187 widths.iter(),
1188 delta_left.iter(),
1189 delta_right.iter(),
1190 ))
1191 .map(|(extent, width, dleft, dright)| {
1192 let (low, high) = (dleft.min(dright), dleft.max(dright));
1193 ((extent.start as i32 - width - high).max(0i32) as u32)
1194 ..((extent.end as i32 - width - low) as u32)
1195 }),
1196 );
1197
1198 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1199
1200 let extent2 = ManifestExtents::from_ranges_iter(
1201 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1202 |(extent, dleft, dright)| {
1203 ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1204 ..((extent.end as i32 + dright + 1) as u32)
1205 },
1206 ),
1207 );
1208 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1209 }
1210
1211 #[icechunk_macros::test]
1217 fn test_from_url_preserves_all_url_parts() -> Result<(), Box<dyn Error>> {
1218 let input = "https://user:pass@host.com:8443/a//b/../c.bin?versionId=42#frag";
1219 let stored = VirtualChunkLocation::from_url(input)?;
1220 assert_eq!(stored.url(), input);
1221 Ok(())
1222 }
1223
1224 #[icechunk_macros::test]
1227 fn test_from_url_object_store_keys_are_verbatim() -> Result<(), Box<dyn Error>> {
1228 for input in [
1229 "s3://foo/bar//../baz",
1230 "gcs://b/a//b",
1231 "s3://bucket/key.with.dots/../sibling",
1232 "az://container/a/./b//c",
1233 "tigris://bucket/x//y",
1234 "https://host/a//b/../c.bin?v=1#frag",
1235 ] {
1236 assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1237 }
1238 let with_parts = "s3://user:pass@host:9000/a//b/../c?x=1#f";
1240 assert_eq!(VirtualChunkLocation::from_url(with_parts)?.url(), with_parts);
1241 Ok(())
1242 }
1243
1244 #[icechunk_macros::test]
1247 fn test_from_url_vcc_is_verbatim() -> Result<(), Box<dyn Error>> {
1248 let input = "vcc://name/a//b/../c";
1249 assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1250 Ok(())
1251 }
1252
1253 #[icechunk_macros::test]
1256 fn test_from_url_file_is_normalized() -> Result<(), Box<dyn Error>> {
1257 assert_eq!(
1258 VirtualChunkLocation::from_url("file:///a/b/../c")?.url(),
1259 "file:///a/c"
1260 );
1261 assert_eq!(
1262 VirtualChunkLocation::from_url("file:///authorized/%2e%2e/secret")?.url(),
1263 "file:///secret"
1264 );
1265 Ok(())
1266 }
1267
1268 #[icechunk_macros::test]
1271 fn test_from_url_lowercases_scheme_only() -> Result<(), Box<dyn Error>> {
1272 assert_eq!(
1273 VirtualChunkLocation::from_url("S3://Bucket/Key//..")?.url(),
1274 "s3://Bucket/Key//.."
1275 );
1276 Ok(())
1277 }
1278
1279 #[icechunk_macros::test]
1280 fn test_from_url_rejects_bad_input() {
1281 use VirtualReferenceErrorKind::*;
1282 let kind =
1283 |s: &str| VirtualChunkLocation::from_url(s).map(|_| ()).unwrap_err().kind;
1284
1285 assert!(matches!(kind("not-a-url"), CannotParseUrl { .. }));
1287 assert!(matches!(kind(""), CannotParseUrl { .. }));
1288 assert!(matches!(kind("mailto:foo"), NoPathSegments(_)));
1290 assert!(matches!(kind("s3:///key"), CannotParseBucketName(_)));
1292 assert!(matches!(kind("vcc:///rel"), NoContainerForName(_)));
1293 assert!(matches!(kind("s3:/bucket/key"), CannotParseBucketName(_)));
1296 }
1297
1298 #[icechunk_macros::test]
1301 fn test_from_url_file_single_slash_accepted() -> Result<(), Box<dyn Error>> {
1302 assert_eq!(VirtualChunkLocation::from_url("file:/x")?.url(), "file:///x");
1303 Ok(())
1304 }
1305
1306 #[icechunk_macros::test]
1307 fn test_overlaps() -> Result<(), Box<dyn Error>> {
1308 let e1 = ManifestExtents::new(
1309 vec![0u32, 1, 2].as_slice(),
1310 vec![2u32, 4, 6].as_slice(),
1311 );
1312
1313 let e2 = ManifestExtents::new(
1314 vec![10u32, 1, 2].as_slice(),
1315 vec![12u32, 4, 6].as_slice(),
1316 );
1317
1318 let union = ManifestExtents::new(
1319 vec![0u32, 1, 2].as_slice(),
1320 vec![12u32, 4, 6].as_slice(),
1321 );
1322
1323 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1324 assert_eq!(e1.intersection(&e2), None);
1325 assert_eq!(e1.union(&e2), union);
1326
1327 let e1 = ManifestExtents::new(
1328 vec![0u32, 1, 2].as_slice(),
1329 vec![2u32, 4, 6].as_slice(),
1330 );
1331 let e2 = ManifestExtents::new(
1332 vec![2u32, 1, 2].as_slice(),
1333 vec![42u32, 4, 6].as_slice(),
1334 );
1335 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1336 assert_eq!(e1.overlap_with(&e2), Overlap::None);
1337
1338 let e1 = ManifestExtents::new(
1340 vec![0u32, 1, 2].as_slice(),
1341 vec![3u32, 4, 6].as_slice(),
1342 );
1343 let e2 = ManifestExtents::new(
1344 vec![2u32, 1, 2].as_slice(),
1345 vec![3u32, 4, 6].as_slice(),
1346 );
1347 let union = ManifestExtents::new(
1348 vec![0u32, 1, 2].as_slice(),
1349 vec![3u32, 4, 6].as_slice(),
1350 );
1351 let intersection = ManifestExtents::new(
1352 vec![2u32, 1, 2].as_slice(),
1353 vec![3u32, 4, 6].as_slice(),
1354 );
1355 assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1356 assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1357 assert_eq!(e1.union(&e2), union.clone());
1358 assert_eq!(e2.union(&e1), union.clone());
1359 assert_eq!(e1.intersection(&e2), Some(intersection));
1360
1361 let e1 = ManifestExtents::new(
1363 vec![0u32, 1, 2].as_slice(),
1364 vec![3u32, 4, 6].as_slice(),
1365 );
1366 let e2 = ManifestExtents::new(
1367 vec![2u32, 1, 2].as_slice(),
1368 vec![2u32, 4, 6].as_slice(),
1369 );
1370 assert_eq!(e1.intersection(&e2), None);
1371
1372 let splits = ManifestSplits::from_edges(vec![
1374 vec![0, 10, 20],
1375 vec![0, 1, 2],
1376 vec![0, 21, 22],
1377 ]);
1378 for vec in splits.iter().combinations(2) {
1379 assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1380 assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1381 }
1382
1383 Ok(())
1384 }
1385
1386 #[alt_proptest]
1387 fn test_manifest_split_from_edges(
1388 #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1389 ) {
1390 let ShapeDim { shape, .. } = shape_dim;
1392
1393 let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1394 let split_shape: Vec<u64> = shape
1395 .iter()
1396 .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1397 .collect();
1398
1399 let ndim = shape.len();
1400 let edges: Vec<Vec<u32>> = (0usize..ndim)
1401 .map(|axis| {
1402 uniform_manifest_split_edges(
1403 num_chunks[axis],
1404 &(split_shape[axis] as u32),
1405 )
1406 })
1407 .collect();
1408
1409 let splits = ManifestSplits::from_edges(edges.into_iter());
1410 for edge in splits.iter() {
1411 prop_assert_eq!(edge.len(), ndim);
1413 for range in edge.iter() {
1414 prop_assert!(range.end > range.start);
1415 }
1416 }
1417
1418 for edges in splits.iter().combinations(2) {
1420 let is_equal =
1421 zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1422 (range1.start == range2.start) && (range1.end == range2.end)
1423 });
1424 prop_assert!(!is_equal);
1425 }
1426 }
1427
1428 const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1429 min_num_chunks: 10,
1430 dictionary_max_training_samples: 500,
1431 dictionary_max_size_bytes: 16 * 1024,
1432 compression_level: 3,
1433 };
1434
1435 fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1436 let node = NodeId::random();
1437 (0..n)
1438 .map(|i| ChunkInfo {
1439 node: node.clone(),
1440 coord: ChunkIndices(vec![i as u32]),
1441 payload: ChunkPayload::Virtual(VirtualChunkRef {
1442 location: VirtualChunkLocation::from_url(&format!(
1443 "s3://my-bucket/path/to/data/chunk_{i:06}"
1444 ))
1445 .unwrap(),
1446 offset: i as u64 * 1024,
1447 length: 1024,
1448 checksum: None,
1449 }),
1450 })
1451 .collect()
1452 }
1453
1454 #[tokio_test]
1455 async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1456 let chunks = make_virtual_chunks(50);
1458 let manifest = Manifest::from_iter(
1459 &ManifestId::random(),
1460 chunks.clone(),
1461 Some(&COMPRESS_CONFIG),
1462 )
1463 .await?
1464 .unwrap();
1465
1466 let root = manifest.root();
1467 assert!(root.location_dictionary().is_some());
1468 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1469 for am in root.arrays().iter() {
1470 for r in am.refs().iter() {
1471 assert!(r.compressed_location().is_some());
1472 assert!(r.location().is_none());
1473 }
1474 }
1475
1476 for chunk in &chunks {
1477 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1478 assert_eq!(payload, chunk.payload);
1479 }
1480 Ok(())
1481 }
1482
1483 #[tokio_test]
1484 async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1485 let chunks = make_virtual_chunks(5);
1487 let manifest = Manifest::from_iter(
1488 &ManifestId::random(),
1489 chunks.clone(),
1490 Some(&COMPRESS_CONFIG),
1491 )
1492 .await?
1493 .unwrap();
1494
1495 let root = manifest.root();
1496 assert!(root.location_dictionary().is_none());
1497 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1498 for am in root.arrays().iter() {
1499 for r in am.refs().iter() {
1500 assert!(r.compressed_location().is_none());
1501 assert!(r.location().is_some());
1502 }
1503 }
1504
1505 for chunk in &chunks {
1506 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1507 assert_eq!(payload, chunk.payload);
1508 }
1509 Ok(())
1510 }
1511
1512 #[tokio_test]
1513 async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1514 let node = NodeId::random();
1516 let mut chunks: Vec<ChunkInfo> = (0..30)
1517 .map(|i| ChunkInfo {
1518 node: node.clone(),
1519 coord: ChunkIndices(vec![i]),
1520 payload: ChunkPayload::Virtual(VirtualChunkRef {
1521 location: VirtualChunkLocation::from_url(&format!(
1522 "s3://my-bucket/path/to/data/chunk_{i:06}"
1523 ))
1524 .unwrap(),
1525 offset: i as u64 * 1024,
1526 length: 1024,
1527 checksum: None,
1528 }),
1529 })
1530 .collect();
1531 chunks.push(ChunkInfo {
1532 node: node.clone(),
1533 coord: ChunkIndices(vec![100]),
1534 payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1535 });
1536 chunks.push(ChunkInfo {
1537 node: node.clone(),
1538 coord: ChunkIndices(vec![101]),
1539 payload: ChunkPayload::Ref(ChunkRef {
1540 id: ChunkId::random(),
1541 offset: 0,
1542 length: 512,
1543 }),
1544 });
1545
1546 let manifest = Manifest::from_iter(
1547 &ManifestId::random(),
1548 chunks.clone(),
1549 Some(&COMPRESS_CONFIG),
1550 )
1551 .await?
1552 .unwrap();
1553
1554 for chunk in &chunks {
1555 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1556 assert_eq!(payload, chunk.payload);
1557 }
1558 Ok(())
1559 }
1560
1561 #[tokio_test]
1562 async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1563 let chunks = make_virtual_chunks(50);
1565 let manifest = Manifest::from_iter(
1566 &ManifestId::random(),
1567 chunks.clone(),
1568 Some(&COMPRESS_CONFIG),
1569 )
1570 .await?
1571 .unwrap();
1572 let bytes = manifest.bytes().to_vec();
1573 let manifest2 = Manifest::from_buffer(bytes)?;
1574
1575 for chunk in &chunks {
1576 let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1577 assert_eq!(payload, chunk.payload);
1578 }
1579 Ok(())
1580 }
1581
1582 #[tokio_test]
1583 async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1584 let chunks = make_virtual_chunks(50);
1586 let node = chunks[0].node.clone();
1587 let manifest = Arc::new(
1588 Manifest::from_iter(
1589 &ManifestId::random(),
1590 chunks.clone(),
1591 Some(&COMPRESS_CONFIG),
1592 )
1593 .await?
1594 .unwrap(),
1595 );
1596
1597 let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1598 assert_eq!(iter_results.len(), 50);
1599 for (i, result) in iter_results.into_iter().enumerate() {
1600 let (coord, payload) = result?;
1601 assert_eq!(coord, chunks[i].coord);
1602 assert_eq!(payload, chunks[i].payload);
1603 }
1604 Ok(())
1605 }
1606
1607 #[tokio_test]
1610 async fn test_decompressed_locations_are_exact_size() -> Result<(), Box<dyn Error>> {
1611 let chunks = make_virtual_chunks(50);
1612 let node = chunks[0].node.clone();
1613 let manifest = Arc::new(
1614 Manifest::from_iter(
1615 &ManifestId::random(),
1616 chunks.clone(),
1617 Some(&COMPRESS_CONFIG),
1618 )
1619 .await?
1620 .unwrap(),
1621 );
1622 assert!(manifest.root().location_dictionary().is_some());
1623
1624 let assert_exact = |payload: ChunkPayload| match payload {
1625 ChunkPayload::Virtual(v) => {
1626 assert_eq!(v.location.0.capacity(), v.location.0.len());
1627 }
1628 other => panic!("expected a virtual payload, got {other:?}"),
1629 };
1630 for payload in manifest.chunk_payloads()? {
1631 assert_exact(payload?);
1632 }
1633 for result in Arc::clone(&manifest).iter(node)? {
1634 assert_exact(result?.1);
1635 }
1636 for chunk in &chunks {
1637 assert_exact(manifest.get_chunk_payload(&chunk.node, &chunk.coord)?);
1638 }
1639 Ok(())
1640 }
1641
1642 #[tokio_test]
1643 async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1644 let chunks = make_virtual_chunks(50);
1646 let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1647 .await?
1648 .unwrap();
1649
1650 let root = manifest.root();
1651 assert!(root.location_dictionary().is_none());
1652 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1653
1654 for am in root.arrays().iter() {
1656 for r in am.refs().iter() {
1657 if r.chunk_id().is_none() && r.inline().is_none() {
1658 assert!(r.location().is_some(), "location should be set");
1659 assert!(
1660 r.compressed_location().is_none(),
1661 "compressed_location should be None"
1662 );
1663 }
1664 }
1665 }
1666
1667 for chunk in &chunks {
1669 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1670 assert_eq!(payload, chunk.payload);
1671 }
1672 Ok(())
1673 }
1674
1675 #[tokio_test]
1676 async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1677 let chunks = make_virtual_chunks(50);
1679 let manifest = Manifest::from_iter(
1680 &ManifestId::random(),
1681 chunks.clone(),
1682 Some(&COMPRESS_CONFIG),
1683 )
1684 .await?
1685 .unwrap();
1686
1687 let root = manifest.root();
1688 assert!(root.location_dictionary().is_some());
1689 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1690
1691 for am in root.arrays().iter() {
1693 for r in am.refs().iter() {
1694 if r.chunk_id().is_none() && r.inline().is_none() {
1695 assert!(
1696 r.compressed_location().is_some(),
1697 "compressed_location should be set"
1698 );
1699 assert!(
1700 r.location().is_none(),
1701 "location should be None for compressed chunks"
1702 );
1703 }
1704 }
1705 }
1706
1707 for chunk in &chunks {
1709 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1710 assert_eq!(payload, chunk.payload);
1711 }
1712 Ok(())
1713 }
1714
1715 #[tokio_test]
1716 async fn test_compression_skipped_when_locations_too_short()
1717 -> Result<(), Box<dyn Error>> {
1718 let config = LocationCompressionConfig {
1722 min_num_chunks: 2,
1723 dictionary_max_training_samples: 500,
1724 dictionary_max_size_bytes: 256,
1725 compression_level: 3,
1726 };
1727
1728 let node = NodeId::random();
1729 let short_chunks: Vec<ChunkInfo> = (0..2)
1731 .map(|i| ChunkInfo {
1732 node: node.clone(),
1733 coord: ChunkIndices(vec![i as u32]),
1734 payload: ChunkPayload::Virtual(VirtualChunkRef {
1735 location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1736 offset: i as u64 * 1024,
1737 length: 1024,
1738 checksum: None,
1739 }),
1740 })
1741 .collect();
1742
1743 let manifest = Manifest::from_iter(
1744 &ManifestId::random(),
1745 short_chunks.clone(),
1746 Some(&config),
1747 )
1748 .await?
1749 .unwrap();
1750
1751 let root = manifest.root();
1752 assert!(root.location_dictionary().is_none());
1753 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1754 for am in root.arrays().iter() {
1755 for r in am.refs().iter() {
1756 assert!(r.compressed_location().is_none());
1757 assert!(r.location().is_some());
1758 }
1759 }
1760
1761 for chunk in &short_chunks {
1762 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1763 assert_eq!(payload, chunk.payload);
1764 }
1765
1766 let long_chunks: Vec<ChunkInfo> = (0..2)
1768 .map(|i| ChunkInfo {
1769 node: node.clone(),
1770 coord: ChunkIndices(vec![i as u32]),
1771 payload: ChunkPayload::Virtual(VirtualChunkRef {
1772 location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1773 offset: i as u64 * 1024,
1774 length: 1024,
1775 checksum: None,
1776 }),
1777 })
1778 .collect();
1779
1780 let manifest = Manifest::from_iter(
1781 &ManifestId::random(),
1782 long_chunks.clone(),
1783 Some(&config),
1784 )
1785 .await?
1786 .unwrap();
1787
1788 let root = manifest.root();
1789 assert!(root.location_dictionary().is_some());
1790 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1791 for am in root.arrays().iter() {
1792 for r in am.refs().iter() {
1793 assert!(r.compressed_location().is_some());
1794 assert!(r.location().is_none());
1795 }
1796 }
1797
1798 for chunk in &long_chunks {
1799 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1800 assert_eq!(payload, chunk.payload);
1801 }
1802 Ok(())
1803 }
1804
1805 #[tokio_test]
1806 async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1807 let node = NodeId::random();
1810 let chunks: Vec<ChunkInfo> = (0..100)
1811 .map(|i| ChunkInfo {
1812 node: node.clone(),
1813 coord: ChunkIndices(vec![i as u32]),
1814 payload: ChunkPayload::Virtual(VirtualChunkRef {
1815 location: VirtualChunkLocation::from_url(&format!(
1816 "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"
1817 ))
1818 .unwrap(),
1819 offset: i as u64 * 4096,
1820 length: 4096,
1821 checksum: None,
1822 }),
1823 })
1824 .collect();
1825
1826 let manifest_uncompressed =
1828 Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1829 .await?
1830 .unwrap();
1831 let size_uncompressed = manifest_uncompressed.bytes().len();
1832
1833 assert!(!manifest_uncompressed.uses_location_compression());
1834 assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1835
1836 let manifest_compressed = Manifest::from_iter(
1838 &ManifestId::random(),
1839 chunks.clone(),
1840 Some(&COMPRESS_CONFIG),
1841 )
1842 .await?
1843 .unwrap();
1844 let size_compressed = manifest_compressed.bytes().len();
1845
1846 assert!(manifest_compressed.uses_location_compression());
1847 assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1848 assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1849
1850 let root = manifest_compressed.root();
1852 for am in root.arrays().iter() {
1853 for r in am.refs().iter() {
1854 let compressed = r.compressed_location().unwrap();
1855 assert!(
1856 compressed.len() < 40,
1857 "compressed_location ({} bytes) should be under 40 bytes \
1858 (raw location is 118 bytes)",
1859 compressed.len(),
1860 );
1861 }
1862 }
1863
1864 assert!(
1866 size_compressed < size_uncompressed,
1867 "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1868 );
1869
1870 let saving_pct =
1871 (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1872 assert!(
1873 saving_pct > 15.0,
1874 "expected at least 15% size reduction with redundant locations, \
1875 got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1876 );
1877
1878 for chunk in &chunks {
1880 let p1 = manifest_uncompressed
1881 .get_chunk_payload(&chunk.node, &chunk.coord)
1882 .unwrap();
1883 let p2 =
1884 manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1885 assert_eq!(p1, chunk.payload);
1886 assert_eq!(p2, chunk.payload);
1887 }
1888
1889 Ok(())
1890 }
1891
1892 #[tokio_test]
1893 async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1894 let chunks = make_virtual_chunks(50);
1896 let manifest = Manifest::from_iter(
1897 &ManifestId::random(),
1898 chunks.clone(),
1899 Some(&COMPRESS_CONFIG),
1900 )
1901 .await?
1902 .unwrap();
1903
1904 let payloads: Vec<_> =
1905 manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1906 assert_eq!(payloads.len(), 50);
1907 for (i, payload) in payloads.iter().enumerate() {
1908 assert_eq!(payload, &chunks[i].payload);
1909 }
1910 Ok(())
1911 }
1912}