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