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
468impl ChunkPayload {
469 pub fn length(&self) -> ChunkLength {
471 match self {
472 ChunkPayload::Inline(bytes) => bytes.len() as ChunkLength,
473 ChunkPayload::Virtual(VirtualChunkRef { length, .. }) => *length,
474 ChunkPayload::Ref(ChunkRef { length, .. }) => *length,
475 }
476 }
477}
478
479#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
480pub struct ChunkInfo {
481 pub node: NodeId,
482 pub coord: ChunkIndices,
483 pub payload: ChunkPayload,
484}
485
486const COMPRESSION_ALG_NONE: u8 = 0;
487const COMPRESSION_ALG_ZSTD_DICT: u8 = 1;
488const MAX_DECOMPRESSED_LOCATION_SIZE: usize = 1_024;
490
491#[derive(PartialEq)]
492pub struct Manifest {
493 buffer: Vec<u8>,
494}
495
496impl std::fmt::Debug for Manifest {
497 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498 f.debug_struct("Manifest")
499 .field("id", &self.id())
500 .field("chunks", &self.len())
501 .finish_non_exhaustive()
502 }
503}
504
505impl Manifest {
506 pub fn id(&self) -> ManifestId {
507 ManifestId::new(self.root().id().0)
508 }
509
510 pub fn bytes(&self) -> &[u8] {
511 self.buffer.as_slice()
512 }
513
514 pub fn from_buffer(buffer: Vec<u8>) -> Result<Manifest, IcechunkFormatError> {
515 let _ = flatbuffers::root_with_opts::<generated::Manifest<'_>>(
516 &ROOT_OPTIONS,
517 buffer.as_slice(),
518 )
519 .capture()?;
520 Ok(Manifest { buffer })
521 }
522
523 fn decompressor(
525 &self,
526 ) -> Result<Option<zstd::bulk::Decompressor<'static>>, IcechunkFormatError> {
527 let root = self.root();
528 if root.compression_algorithm() != COMPRESSION_ALG_ZSTD_DICT {
529 return Ok(None);
530 }
531 match root.location_dictionary() {
532 Some(dict_bytes) => {
533 let decompressor =
534 zstd::bulk::Decompressor::with_dictionary(dict_bytes.bytes())
535 .capture()?;
536 Ok(Some(decompressor))
537 }
538 None => Ok(None),
539 }
540 }
541
542 pub fn from_sorted_vec(
543 manifest_id: &ManifestId,
544 sorted_chunks: Vec<ChunkInfo>,
545 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
546 ) -> IcechunkResult<Option<Self>> {
547 let location_compression_dict =
548 train_location_dictionary(&sorted_chunks, virtual_chunks_compression_config)?;
549 let compressed_locations =
551 match (&location_compression_dict, virtual_chunks_compression_config) {
552 (Some(d), Some(config)) => compress_locations(&sorted_chunks, d, config),
553 _ => vec![None; sorted_chunks.len()],
554 };
555
556 let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024);
559
560 let len = sorted_chunks.len();
561 let mut all = sorted_chunks.into_iter().zip(compressed_locations).peekable();
562
563 let mut array_manifests = Vec::with_capacity(1);
564 while let Some(current_node) = all.peek().map(|(chunk, _)| chunk.node.clone()) {
565 let mut refs = Vec::with_capacity(len);
567 while let Some((chunk, precompressed)) =
568 all.next_if(|(chunk, _)| chunk.node == current_node)
569 {
570 refs.push(mk_chunk_ref(&mut builder, chunk, precompressed));
571 }
572
573 let node_id = Some(generated::ObjectId8::new(¤t_node.0));
574 let refs = Some(builder.create_vector(refs.as_slice()));
575 let array_manifest = generated::ArrayManifest::create(
576 &mut builder,
577 &generated::ArrayManifestArgs {
578 node_id: node_id.as_ref(),
579 refs,
580 ..Default::default()
581 },
582 );
583 array_manifests.push(array_manifest);
584 }
585
586 if array_manifests.is_empty() {
587 return Ok(None);
589 }
590
591 let arrays = builder.create_vector(array_manifests.as_slice());
592 let bin_manifest_id = generated::ObjectId12::new(&manifest_id.0);
593
594 let (location_dictionary, compression_algorithm) =
595 if let Some(ref dict) = location_compression_dict {
596 (Some(builder.create_vector(dict.as_slice())), COMPRESSION_ALG_ZSTD_DICT)
597 } else {
598 (None, COMPRESSION_ALG_NONE)
599 };
600
601 let manifest = generated::Manifest::create(
602 &mut builder,
603 &generated::ManifestArgs {
604 id: Some(&bin_manifest_id),
605 arrays: Some(arrays),
606 location_dictionary,
607 compression_algorithm,
608 ..Default::default()
609 },
610 );
611
612 builder.finish(manifest, Some("Ichk"));
613 let (mut buffer, offset) = builder.collapse();
614 buffer.drain(0..offset);
615 buffer.shrink_to_fit();
616 Ok(Some(Manifest { buffer }))
617 }
618
619 pub async fn from_stream<E>(
620 manifest_id: &ManifestId,
621 stream: impl Stream<Item = Result<ChunkInfo, E>>,
622 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
623 ) -> Result<Option<Self>, E>
624 where
625 E: From<IcechunkFormatError>,
626 {
627 let mut all = stream.try_collect::<Vec<_>>().await?;
628 all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord)));
629 Ok(Self::from_sorted_vec(manifest_id, all, virtual_chunks_compression_config)?)
630 }
631
632 pub async fn from_iter<T: IntoIterator<Item = ChunkInfo>>(
634 manifest_id: &ManifestId,
635 iter: T,
636 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
637 ) -> IcechunkResult<Option<Self>> {
638 Self::from_stream(
639 manifest_id,
640 futures::stream::iter(iter.into_iter().map(Ok::<_, IcechunkFormatError>)),
641 virtual_chunks_compression_config,
642 )
643 .await
644 }
645
646 pub fn len(&self) -> usize {
647 self.root().arrays().iter().map(|am| am.refs().len()).sum()
648 }
649
650 #[must_use]
651 pub fn is_empty(&self) -> bool {
652 self.len() == 0
653 }
654
655 #[expect(unsafe_code)]
656 fn root(&self) -> generated::Manifest<'_> {
657 unsafe { flatbuffers::root_unchecked::<generated::Manifest<'_>>(&self.buffer) }
661 }
662
663 pub fn arrays(&self) -> impl Iterator<Item = NodeId> {
664 self.root().arrays().iter().map(|am| NodeId::from(am.node_id().0))
665 }
666
667 pub fn uses_location_compression(&self) -> bool {
668 self.root().compression_algorithm() != COMPRESSION_ALG_NONE
669 }
670
671 pub fn location_dictionary_size(&self) -> Option<usize> {
672 self.root().location_dictionary().map(|d| d.len())
673 }
674
675 pub fn num_compressed_refs(&self) -> usize {
676 self.root()
677 .arrays()
678 .iter()
679 .flat_map(|am| am.refs().iter())
680 .filter(|r| r.compressed_location().is_some())
681 .count()
682 }
683
684 pub fn get_chunk_payload(
685 &self,
686 node: &NodeId,
687 coord: &ChunkIndices,
688 ) -> IcechunkResult<ChunkPayload> {
689 let mut decompressor = self.decompressor()?;
690 let manifest = self.root();
691 let chunk_ref = lookup_node(manifest, node)
692 .and_then(|array_manifest| lookup_ref(array_manifest, coord))
693 .ok_or_else(|| IcechunkFormatErrorKind::ChunkCoordinatesNotFound {
694 coords: coord.clone(),
695 })
696 .capture()?;
697 ref_to_payload(chunk_ref, decompressor.as_mut())
698 }
699
700 pub fn iter(
701 self: Arc<Self>,
702 node: NodeId,
703 ) -> Result<
704 impl Iterator<Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>>,
705 IcechunkFormatError,
706 > {
707 PayloadIterator::new(self, node)
708 }
709
710 pub fn chunk_payloads(
711 &self,
712 ) -> Result<
713 impl Iterator<Item = Result<ChunkPayload, IcechunkFormatError>> + '_,
714 IcechunkFormatError,
715 > {
716 let mut decompressor = self.decompressor()?;
717 let refs: Vec<_> =
718 self.root().arrays().iter().flat_map(|am| am.refs().iter()).collect();
719 Ok(refs.into_iter().map(move |r| ref_to_payload(r, decompressor.as_mut())))
720 }
721}
722
723fn lookup_node<'a>(
724 manifest: generated::Manifest<'a>,
725 node: &NodeId,
726) -> Option<generated::ArrayManifest<'a>> {
727 manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id))
728}
729
730fn lookup_ref<'a>(
731 array_manifest: generated::ArrayManifest<'a>,
732 coord: &ChunkIndices,
733) -> Option<generated::ChunkRef<'a>> {
734 array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| {
735 chunk_ref.index().iter().cmp(coords.iter().copied())
736 })
737}
738
739pub struct PayloadIterator {
740 manifest: Arc<Manifest>,
741 node_id: NodeId,
742 last_ref_index: usize,
743 decompressor: Option<zstd::bulk::Decompressor<'static>>,
744}
745
746impl std::fmt::Debug for PayloadIterator {
747 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748 f.debug_struct("PayloadIterator")
749 .field("node_id", &self.node_id)
750 .field("last_ref_index", &self.last_ref_index)
751 .finish_non_exhaustive()
752 }
753}
754
755impl PayloadIterator {
756 fn new(
757 manifest: Arc<Manifest>,
758 node_id: NodeId,
759 ) -> Result<Self, IcechunkFormatError> {
760 let decompressor = manifest.decompressor()?;
761 Ok(Self { manifest, node_id, last_ref_index: 0, decompressor })
762 }
763}
764
765impl Iterator for PayloadIterator {
766 type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>;
767
768 fn next(&mut self) -> Option<Self::Item> {
769 let manifest = self.manifest.root();
770 lookup_node(manifest, &self.node_id).and_then(|array_manifest| {
771 let refs = array_manifest.refs();
772 if self.last_ref_index >= refs.len() {
773 return None;
774 }
775
776 let chunk_ref = refs.get(self.last_ref_index);
777 self.last_ref_index += 1;
778 Some(
779 ref_to_payload(chunk_ref, self.decompressor.as_mut())
780 .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)),
781 )
782 })
783 }
784}
785
786fn ref_to_payload(
787 chunk_ref: generated::ChunkRef<'_>,
788 decompressor: Option<&mut zstd::bulk::Decompressor<'static>>,
789) -> Result<ChunkPayload, IcechunkFormatError> {
790 if let Some(chunk_id) = chunk_ref.chunk_id() {
791 let id = ChunkId::new(chunk_id.0);
792 Ok(ChunkPayload::Ref(ChunkRef {
793 id,
794 offset: chunk_ref.offset(),
795 length: chunk_ref.length(),
796 }))
797 } else if let Some(compressed) = chunk_ref.compressed_location() {
798 let decompressor = decompressor
799 .ok_or(IcechunkFormatErrorKind::MissingLocationCompressionDictionary)
800 .capture()?;
801 let decompressed = decompressor
802 .decompress(compressed.bytes(), MAX_DECOMPRESSED_LOCATION_SIZE)
803 .capture()?;
804 let location_string = String::from_utf8(decompressed)
805 .map_err(|e| {
806 IcechunkFormatErrorKind::IO(std::io::Error::new(
807 std::io::ErrorKind::InvalidData,
808 e,
809 ))
810 })
811 .capture()?;
812 let location = VirtualChunkLocation::from_trusted(location_string);
813 Ok(ChunkPayload::Virtual(VirtualChunkRef {
814 location,
815 checksum: checksum(&chunk_ref),
816 offset: chunk_ref.offset(),
817 length: chunk_ref.length(),
818 }))
819 } else if let Some(location) = chunk_ref.location() {
820 let location = VirtualChunkLocation::from_trusted(location.to_string());
821 Ok(ChunkPayload::Virtual(VirtualChunkRef {
822 location,
823 checksum: checksum(&chunk_ref),
824 offset: chunk_ref.offset(),
825 length: chunk_ref.length(),
826 }))
827 } else if let Some(data) = chunk_ref.inline() {
828 Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
829 } else {
830 Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
831 flatbuffers::InvalidFlatbuffer::InconsistentUnion {
832 field: Cow::Borrowed("chunk_id+location+inline"),
833 field_type: Cow::Borrowed("invalid"),
834 error_trace: Default::default(),
835 },
836 ))
837 .capture()
838 }
839}
840
841fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
842 if let Some(etag) = payload.checksum_etag() {
843 Some(Checksum::ETag(ETag(etag.to_string())))
844 } else if payload.checksum_last_modified() > 0 {
845 Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
846 } else {
847 None
848 }
849}
850
851fn train_location_dictionary(
860 chunks: &[ChunkInfo],
861 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
862) -> IcechunkResult<Option<Vec<u8>>> {
863 let Some(config) = virtual_chunks_compression_config else {
864 return Ok(None);
865 };
866 let max_samples = config.dictionary_max_training_samples as usize;
867 let min_chunks = config.min_num_chunks as usize;
868 let max_dict_size = config.dictionary_max_size_bytes as usize;
869
870 let mut virtual_count: usize = 0;
871 let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
872 let mut rng: SmallRng = rand::make_rng();
873
874 if chunks.len() < min_chunks {
875 return Ok(None);
876 }
877
878 for chunk in chunks {
879 if let ChunkPayload::Virtual(vref) = &chunk.payload {
880 let loc = vref.location.url();
881 if virtual_count < max_samples {
882 reservoir.push(loc);
884 } else {
885 let j = rng.random_range(0..=virtual_count);
887 if j < max_samples {
888 reservoir[j] = loc;
889 }
890 }
891 virtual_count += 1;
892 }
893 }
894
895 if virtual_count < min_chunks {
896 return Ok(None);
897 }
898
899 let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
900
901 let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
903 if small_count >= sample_bytes.len() / 2 {
904 tracing::warn!(
905 "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
906 sample_bytes.len()
907 );
908 return Ok(None);
909 }
910
911 let mut sample_data: Vec<u8> =
912 sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
913 let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
914 let total_sample_size = sample_data.len();
915
916 if total_sample_size > 0 && total_sample_size < max_dict_size {
918 let repeats = (max_dict_size / total_sample_size) + 1;
919 let original_data = sample_data.clone();
920 let original_sizes = sample_sizes.clone();
921 for _ in 0..repeats {
922 sample_data.extend_from_slice(&original_data);
923 sample_sizes.extend_from_slice(&original_sizes);
924 }
925 }
926
927 Ok(Some(
928 zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
929 .capture()?,
930 ))
931}
932
933fn compress_locations(
938 chunks: &[ChunkInfo],
939 dict: &[u8],
940 config: &LocationCompressionConfig,
941) -> Vec<Option<Vec<u8>>> {
942 let compression_level = config.compression_level;
943
944 #[cfg(not(target_family = "wasm"))]
945 {
946 let num_threads =
947 std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
948 let slice_size = chunks.len().div_ceil(num_threads);
949
950 std::thread::scope(|s| {
951 let handles: Vec<_> = chunks
952 .chunks(slice_size)
953 .map(|slice| {
954 s.spawn(|| {
955 let mut comp = zstd::bulk::Compressor::with_dictionary(
956 compression_level,
957 dict,
958 )
959 .ok();
960 slice
961 .iter()
962 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
963 (ChunkPayload::Virtual(vref), Some(comp)) => {
964 comp.compress(vref.location.url().as_bytes()).ok()
965 }
966 _ => None,
967 })
968 .collect::<Vec<_>>()
969 })
970 })
971 .collect();
972
973 #[expect(clippy::expect_used)]
974 handles
975 .into_iter()
976 .flat_map(|h| {
977 h.join()
978 .expect("Cannot join threads compressing virtual chunk locations")
979 })
980 .collect()
981 })
982 }
983
984 #[cfg(target_family = "wasm")]
985 {
986 let mut comp =
987 zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
988 chunks
989 .iter()
990 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
991 (ChunkPayload::Virtual(vref), Some(comp)) => {
992 comp.compress(vref.location.url().as_bytes()).ok()
993 }
994 _ => None,
995 })
996 .collect()
997 }
998}
999
1000fn mk_chunk_ref<'bldr>(
1001 builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
1002 chunk: ChunkInfo,
1003 precompressed_location: Option<Vec<u8>>,
1004) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
1005 let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
1006 match chunk.payload {
1007 ChunkPayload::Inline(bytes) => {
1008 let bytes = builder.create_vector(bytes.as_ref());
1009 let args = generated::ChunkRefArgs {
1010 inline: Some(bytes),
1011 index,
1012 ..Default::default()
1013 };
1014 generated::ChunkRef::create(builder, &args)
1015 }
1016 ChunkPayload::Virtual(virtual_chunk_ref) => {
1017 let location_str = virtual_chunk_ref.location.0.as_str();
1018 let (location, compressed_location) =
1019 if let Some(compressed) = precompressed_location {
1020 (None, Some(builder.create_vector(&compressed)))
1021 } else {
1022 (Some(builder.create_string(location_str)), None)
1023 };
1024 let args = generated::ChunkRefArgs {
1025 index,
1026 location,
1027 compressed_location,
1028 offset: virtual_chunk_ref.offset,
1029 length: virtual_chunk_ref.length,
1030 checksum_etag: match &virtual_chunk_ref.checksum {
1031 Some(cs) => match cs {
1032 Checksum::LastModified(_) => None,
1033 Checksum::ETag(etag) => {
1034 Some(builder.create_string(etag.0.as_str()))
1035 }
1036 },
1037 None => None,
1038 },
1039 checksum_last_modified: match &virtual_chunk_ref.checksum {
1040 Some(cs) => match cs {
1041 Checksum::LastModified(seconds) => seconds.0,
1042 Checksum::ETag(_) => 0,
1043 },
1044 None => 0,
1045 },
1046 ..Default::default()
1047 };
1048 generated::ChunkRef::create(builder, &args)
1049 }
1050 ChunkPayload::Ref(chunk_ref) => {
1051 let id = generated::ObjectId12::new(&chunk_ref.id.0);
1052 let args = generated::ChunkRefArgs {
1053 index,
1054 offset: chunk_ref.offset,
1055 length: chunk_ref.length,
1056 chunk_id: Some(&id),
1057 ..Default::default()
1058 };
1059 generated::ChunkRef::create(builder, &args)
1060 }
1061 }
1062}
1063
1064static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1065 max_depth: 64,
1066 max_tables: 500_000_000,
1067 max_apparent_size: 1 << 31, ignore_missing_null_terminator: true,
1069};
1070
1071#[cfg(test)]
1072#[expect(unused_qualifications)] mod tests {
1074 use super::*;
1075 use crate::roundtrip_serialization_tests;
1076 use crate::strategies::{
1077 ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1078 manifest_splits, shapes_and_dims,
1079 };
1080 use icechunk_macros::{self, tokio_test};
1081 use itertools::{all, multizip};
1082 use proptest::collection::vec;
1083 use proptest::prelude::*;
1084 use std::error::Error;
1085 use test_strategy::proptest as alt_proptest;
1086
1087 roundtrip_serialization_tests!(
1088 serialize_and_deserialize_manifest_ref - manifest_ref,
1089 serialize_and_deserialize_manifest_splits - manifest_splits
1090 );
1091
1092 #[alt_proptest]
1093 fn test_property_extents_set_ops_same(
1094 #[strategy(manifest_extents(4))] e: ManifestExtents,
1095 ) {
1096 prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1097 prop_assert_eq!(e.union(&e), e.clone());
1098 prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1099 }
1100
1101 #[alt_proptest]
1102 fn test_property_extents_set_ops(
1103 #[strategy(manifest_extents(4))] e1: ManifestExtents,
1104 #[strategy(manifest_extents(4))] e2: ManifestExtents,
1105 ) {
1106 let union = e1.union(&e2);
1107 let intersection = e1.intersection(&e2);
1108
1109 prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1110 prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1111 prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1112 prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1113
1114 prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1116 prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1117
1118 if intersection.is_some() {
1119 let int = intersection.unwrap();
1120 let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1121 prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1122 prop_assert_eq!(int.overlap_with(&e2), expected);
1123 } else {
1124 prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1125 prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1126 }
1127 }
1128
1129 #[alt_proptest]
1130 fn test_property_extents_widths(
1131 #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1132 #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1133 #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1134 ) {
1135 let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1136 let extent2 = ManifestExtents::from_ranges_iter(
1137 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1138 |(extent, dleft, dright)| {
1139 ((extent.start as i32 + dleft) as u32)
1140 ..((extent.end as i32 + dright) as u32)
1141 },
1142 ),
1143 );
1144
1145 if all(delta_left.iter(), |elem| elem == &0i32)
1146 && all(delta_right.iter(), |elem| elem == &0i32)
1147 {
1148 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1149 }
1150
1151 let extent2 = ManifestExtents::from_ranges_iter(
1152 multizip((
1153 extent1.iter(),
1154 widths.iter(),
1155 delta_left.iter(),
1156 delta_right.iter(),
1157 ))
1158 .map(|(extent, width, dleft, dright)| {
1159 let (low, high) = (dleft.min(dright), dleft.max(dright));
1160 ((extent.start as i32 + width + low) as u32)
1161 ..((extent.end as i32 + width + high) as u32)
1162 }),
1163 );
1164
1165 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1166
1167 let extent2 = ManifestExtents::from_ranges_iter(
1168 multizip((
1169 extent1.iter(),
1170 widths.iter(),
1171 delta_left.iter(),
1172 delta_right.iter(),
1173 ))
1174 .map(|(extent, width, dleft, dright)| {
1175 let (low, high) = (dleft.min(dright), dleft.max(dright));
1176 ((extent.start as i32 - width - high).max(0i32) as u32)
1177 ..((extent.end as i32 - width - low) as u32)
1178 }),
1179 );
1180
1181 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1182
1183 let extent2 = ManifestExtents::from_ranges_iter(
1184 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1185 |(extent, dleft, dright)| {
1186 ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1187 ..((extent.end as i32 + dright + 1) as u32)
1188 },
1189 ),
1190 );
1191 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1192 }
1193
1194 #[icechunk_macros::test]
1200 fn test_from_url_preserves_all_url_parts() -> Result<(), Box<dyn Error>> {
1201 let input = "https://user:pass@host.com:8443/a//b/../c.bin?versionId=42#frag";
1202 let stored = VirtualChunkLocation::from_url(input)?;
1203 assert_eq!(stored.url(), input);
1204 Ok(())
1205 }
1206
1207 #[icechunk_macros::test]
1210 fn test_from_url_object_store_keys_are_verbatim() -> Result<(), Box<dyn Error>> {
1211 for input in [
1212 "s3://foo/bar//../baz",
1213 "gcs://b/a//b",
1214 "s3://bucket/key.with.dots/../sibling",
1215 "az://container/a/./b//c",
1216 "tigris://bucket/x//y",
1217 "https://host/a//b/../c.bin?v=1#frag",
1218 ] {
1219 assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1220 }
1221 let with_parts = "s3://user:pass@host:9000/a//b/../c?x=1#f";
1223 assert_eq!(VirtualChunkLocation::from_url(with_parts)?.url(), with_parts);
1224 Ok(())
1225 }
1226
1227 #[icechunk_macros::test]
1230 fn test_from_url_vcc_is_verbatim() -> Result<(), Box<dyn Error>> {
1231 let input = "vcc://name/a//b/../c";
1232 assert_eq!(VirtualChunkLocation::from_url(input)?.url(), input);
1233 Ok(())
1234 }
1235
1236 #[icechunk_macros::test]
1239 fn test_from_url_file_is_normalized() -> Result<(), Box<dyn Error>> {
1240 assert_eq!(
1241 VirtualChunkLocation::from_url("file:///a/b/../c")?.url(),
1242 "file:///a/c"
1243 );
1244 assert_eq!(
1245 VirtualChunkLocation::from_url("file:///authorized/%2e%2e/secret")?.url(),
1246 "file:///secret"
1247 );
1248 Ok(())
1249 }
1250
1251 #[icechunk_macros::test]
1254 fn test_from_url_lowercases_scheme_only() -> Result<(), Box<dyn Error>> {
1255 assert_eq!(
1256 VirtualChunkLocation::from_url("S3://Bucket/Key//..")?.url(),
1257 "s3://Bucket/Key//.."
1258 );
1259 Ok(())
1260 }
1261
1262 #[icechunk_macros::test]
1263 fn test_from_url_rejects_bad_input() {
1264 use VirtualReferenceErrorKind::*;
1265 let kind =
1266 |s: &str| VirtualChunkLocation::from_url(s).map(|_| ()).unwrap_err().kind;
1267
1268 assert!(matches!(kind("not-a-url"), CannotParseUrl { .. }));
1270 assert!(matches!(kind(""), CannotParseUrl { .. }));
1271 assert!(matches!(kind("mailto:foo"), NoPathSegments(_)));
1273 assert!(matches!(kind("s3:///key"), CannotParseBucketName(_)));
1275 assert!(matches!(kind("vcc:///rel"), NoContainerForName(_)));
1276 assert!(matches!(kind("s3:/bucket/key"), CannotParseBucketName(_)));
1279 }
1280
1281 #[icechunk_macros::test]
1284 fn test_from_url_file_single_slash_accepted() -> Result<(), Box<dyn Error>> {
1285 assert_eq!(VirtualChunkLocation::from_url("file:/x")?.url(), "file:///x");
1286 Ok(())
1287 }
1288
1289 #[icechunk_macros::test]
1290 fn test_overlaps() -> Result<(), Box<dyn Error>> {
1291 let e1 = ManifestExtents::new(
1292 vec![0u32, 1, 2].as_slice(),
1293 vec![2u32, 4, 6].as_slice(),
1294 );
1295
1296 let e2 = ManifestExtents::new(
1297 vec![10u32, 1, 2].as_slice(),
1298 vec![12u32, 4, 6].as_slice(),
1299 );
1300
1301 let union = ManifestExtents::new(
1302 vec![0u32, 1, 2].as_slice(),
1303 vec![12u32, 4, 6].as_slice(),
1304 );
1305
1306 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1307 assert_eq!(e1.intersection(&e2), None);
1308 assert_eq!(e1.union(&e2), union);
1309
1310 let e1 = ManifestExtents::new(
1311 vec![0u32, 1, 2].as_slice(),
1312 vec![2u32, 4, 6].as_slice(),
1313 );
1314 let e2 = ManifestExtents::new(
1315 vec![2u32, 1, 2].as_slice(),
1316 vec![42u32, 4, 6].as_slice(),
1317 );
1318 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1319 assert_eq!(e1.overlap_with(&e2), Overlap::None);
1320
1321 let e1 = ManifestExtents::new(
1323 vec![0u32, 1, 2].as_slice(),
1324 vec![3u32, 4, 6].as_slice(),
1325 );
1326 let e2 = ManifestExtents::new(
1327 vec![2u32, 1, 2].as_slice(),
1328 vec![3u32, 4, 6].as_slice(),
1329 );
1330 let union = ManifestExtents::new(
1331 vec![0u32, 1, 2].as_slice(),
1332 vec![3u32, 4, 6].as_slice(),
1333 );
1334 let intersection = ManifestExtents::new(
1335 vec![2u32, 1, 2].as_slice(),
1336 vec![3u32, 4, 6].as_slice(),
1337 );
1338 assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1339 assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1340 assert_eq!(e1.union(&e2), union.clone());
1341 assert_eq!(e2.union(&e1), union.clone());
1342 assert_eq!(e1.intersection(&e2), Some(intersection));
1343
1344 let e1 = ManifestExtents::new(
1346 vec![0u32, 1, 2].as_slice(),
1347 vec![3u32, 4, 6].as_slice(),
1348 );
1349 let e2 = ManifestExtents::new(
1350 vec![2u32, 1, 2].as_slice(),
1351 vec![2u32, 4, 6].as_slice(),
1352 );
1353 assert_eq!(e1.intersection(&e2), None);
1354
1355 let splits = ManifestSplits::from_edges(vec![
1357 vec![0, 10, 20],
1358 vec![0, 1, 2],
1359 vec![0, 21, 22],
1360 ]);
1361 for vec in splits.iter().combinations(2) {
1362 assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1363 assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1364 }
1365
1366 Ok(())
1367 }
1368
1369 #[alt_proptest]
1370 fn test_manifest_split_from_edges(
1371 #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1372 ) {
1373 let ShapeDim { shape, .. } = shape_dim;
1375
1376 let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1377 let split_shape: Vec<u64> = shape
1378 .iter()
1379 .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1380 .collect();
1381
1382 let ndim = shape.len();
1383 let edges: Vec<Vec<u32>> = (0usize..ndim)
1384 .map(|axis| {
1385 uniform_manifest_split_edges(
1386 num_chunks[axis],
1387 &(split_shape[axis] as u32),
1388 )
1389 })
1390 .collect();
1391
1392 let splits = ManifestSplits::from_edges(edges.into_iter());
1393 for edge in splits.iter() {
1394 prop_assert_eq!(edge.len(), ndim);
1396 for range in edge.iter() {
1397 prop_assert!(range.end > range.start);
1398 }
1399 }
1400
1401 for edges in splits.iter().combinations(2) {
1403 let is_equal =
1404 zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1405 (range1.start == range2.start) && (range1.end == range2.end)
1406 });
1407 prop_assert!(!is_equal);
1408 }
1409 }
1410
1411 const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1412 min_num_chunks: 10,
1413 dictionary_max_training_samples: 500,
1414 dictionary_max_size_bytes: 16 * 1024,
1415 compression_level: 3,
1416 };
1417
1418 fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1419 let node = NodeId::random();
1420 (0..n)
1421 .map(|i| ChunkInfo {
1422 node: node.clone(),
1423 coord: ChunkIndices(vec![i as u32]),
1424 payload: ChunkPayload::Virtual(VirtualChunkRef {
1425 location: VirtualChunkLocation::from_url(&format!(
1426 "s3://my-bucket/path/to/data/chunk_{i:06}"
1427 ))
1428 .unwrap(),
1429 offset: i as u64 * 1024,
1430 length: 1024,
1431 checksum: None,
1432 }),
1433 })
1434 .collect()
1435 }
1436
1437 #[tokio_test]
1438 async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1439 let chunks = make_virtual_chunks(50);
1441 let manifest = Manifest::from_iter(
1442 &ManifestId::random(),
1443 chunks.clone(),
1444 Some(&COMPRESS_CONFIG),
1445 )
1446 .await?
1447 .unwrap();
1448
1449 let root = manifest.root();
1450 assert!(root.location_dictionary().is_some());
1451 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1452 for am in root.arrays().iter() {
1453 for r in am.refs().iter() {
1454 assert!(r.compressed_location().is_some());
1455 assert!(r.location().is_none());
1456 }
1457 }
1458
1459 for chunk in &chunks {
1460 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1461 assert_eq!(payload, chunk.payload);
1462 }
1463 Ok(())
1464 }
1465
1466 #[tokio_test]
1467 async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1468 let chunks = make_virtual_chunks(5);
1470 let manifest = Manifest::from_iter(
1471 &ManifestId::random(),
1472 chunks.clone(),
1473 Some(&COMPRESS_CONFIG),
1474 )
1475 .await?
1476 .unwrap();
1477
1478 let root = manifest.root();
1479 assert!(root.location_dictionary().is_none());
1480 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1481 for am in root.arrays().iter() {
1482 for r in am.refs().iter() {
1483 assert!(r.compressed_location().is_none());
1484 assert!(r.location().is_some());
1485 }
1486 }
1487
1488 for chunk in &chunks {
1489 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1490 assert_eq!(payload, chunk.payload);
1491 }
1492 Ok(())
1493 }
1494
1495 #[tokio_test]
1496 async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1497 let node = NodeId::random();
1499 let mut chunks: Vec<ChunkInfo> = (0..30)
1500 .map(|i| ChunkInfo {
1501 node: node.clone(),
1502 coord: ChunkIndices(vec![i]),
1503 payload: ChunkPayload::Virtual(VirtualChunkRef {
1504 location: VirtualChunkLocation::from_url(&format!(
1505 "s3://my-bucket/path/to/data/chunk_{i:06}"
1506 ))
1507 .unwrap(),
1508 offset: i as u64 * 1024,
1509 length: 1024,
1510 checksum: None,
1511 }),
1512 })
1513 .collect();
1514 chunks.push(ChunkInfo {
1515 node: node.clone(),
1516 coord: ChunkIndices(vec![100]),
1517 payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1518 });
1519 chunks.push(ChunkInfo {
1520 node: node.clone(),
1521 coord: ChunkIndices(vec![101]),
1522 payload: ChunkPayload::Ref(ChunkRef {
1523 id: ChunkId::random(),
1524 offset: 0,
1525 length: 512,
1526 }),
1527 });
1528
1529 let manifest = Manifest::from_iter(
1530 &ManifestId::random(),
1531 chunks.clone(),
1532 Some(&COMPRESS_CONFIG),
1533 )
1534 .await?
1535 .unwrap();
1536
1537 for chunk in &chunks {
1538 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1539 assert_eq!(payload, chunk.payload);
1540 }
1541 Ok(())
1542 }
1543
1544 #[tokio_test]
1545 async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1546 let chunks = make_virtual_chunks(50);
1548 let manifest = Manifest::from_iter(
1549 &ManifestId::random(),
1550 chunks.clone(),
1551 Some(&COMPRESS_CONFIG),
1552 )
1553 .await?
1554 .unwrap();
1555 let bytes = manifest.bytes().to_vec();
1556 let manifest2 = Manifest::from_buffer(bytes)?;
1557
1558 for chunk in &chunks {
1559 let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1560 assert_eq!(payload, chunk.payload);
1561 }
1562 Ok(())
1563 }
1564
1565 #[tokio_test]
1566 async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1567 let chunks = make_virtual_chunks(50);
1569 let node = chunks[0].node.clone();
1570 let manifest = Arc::new(
1571 Manifest::from_iter(
1572 &ManifestId::random(),
1573 chunks.clone(),
1574 Some(&COMPRESS_CONFIG),
1575 )
1576 .await?
1577 .unwrap(),
1578 );
1579
1580 let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1581 assert_eq!(iter_results.len(), 50);
1582 for (i, result) in iter_results.into_iter().enumerate() {
1583 let (coord, payload) = result?;
1584 assert_eq!(coord, chunks[i].coord);
1585 assert_eq!(payload, chunks[i].payload);
1586 }
1587 Ok(())
1588 }
1589
1590 #[tokio_test]
1591 async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1592 let chunks = make_virtual_chunks(50);
1594 let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1595 .await?
1596 .unwrap();
1597
1598 let root = manifest.root();
1599 assert!(root.location_dictionary().is_none());
1600 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1601
1602 for am in root.arrays().iter() {
1604 for r in am.refs().iter() {
1605 if r.chunk_id().is_none() && r.inline().is_none() {
1606 assert!(r.location().is_some(), "location should be set");
1607 assert!(
1608 r.compressed_location().is_none(),
1609 "compressed_location should be None"
1610 );
1611 }
1612 }
1613 }
1614
1615 for chunk in &chunks {
1617 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1618 assert_eq!(payload, chunk.payload);
1619 }
1620 Ok(())
1621 }
1622
1623 #[tokio_test]
1624 async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1625 let chunks = make_virtual_chunks(50);
1627 let manifest = Manifest::from_iter(
1628 &ManifestId::random(),
1629 chunks.clone(),
1630 Some(&COMPRESS_CONFIG),
1631 )
1632 .await?
1633 .unwrap();
1634
1635 let root = manifest.root();
1636 assert!(root.location_dictionary().is_some());
1637 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1638
1639 for am in root.arrays().iter() {
1641 for r in am.refs().iter() {
1642 if r.chunk_id().is_none() && r.inline().is_none() {
1643 assert!(
1644 r.compressed_location().is_some(),
1645 "compressed_location should be set"
1646 );
1647 assert!(
1648 r.location().is_none(),
1649 "location should be None for compressed chunks"
1650 );
1651 }
1652 }
1653 }
1654
1655 for chunk in &chunks {
1657 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1658 assert_eq!(payload, chunk.payload);
1659 }
1660 Ok(())
1661 }
1662
1663 #[tokio_test]
1664 async fn test_compression_skipped_when_locations_too_short()
1665 -> Result<(), Box<dyn Error>> {
1666 let config = LocationCompressionConfig {
1670 min_num_chunks: 2,
1671 dictionary_max_training_samples: 500,
1672 dictionary_max_size_bytes: 256,
1673 compression_level: 3,
1674 };
1675
1676 let node = NodeId::random();
1677 let short_chunks: Vec<ChunkInfo> = (0..2)
1679 .map(|i| ChunkInfo {
1680 node: node.clone(),
1681 coord: ChunkIndices(vec![i as u32]),
1682 payload: ChunkPayload::Virtual(VirtualChunkRef {
1683 location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1684 offset: i as u64 * 1024,
1685 length: 1024,
1686 checksum: None,
1687 }),
1688 })
1689 .collect();
1690
1691 let manifest = Manifest::from_iter(
1692 &ManifestId::random(),
1693 short_chunks.clone(),
1694 Some(&config),
1695 )
1696 .await?
1697 .unwrap();
1698
1699 let root = manifest.root();
1700 assert!(root.location_dictionary().is_none());
1701 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1702 for am in root.arrays().iter() {
1703 for r in am.refs().iter() {
1704 assert!(r.compressed_location().is_none());
1705 assert!(r.location().is_some());
1706 }
1707 }
1708
1709 for chunk in &short_chunks {
1710 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1711 assert_eq!(payload, chunk.payload);
1712 }
1713
1714 let long_chunks: Vec<ChunkInfo> = (0..2)
1716 .map(|i| ChunkInfo {
1717 node: node.clone(),
1718 coord: ChunkIndices(vec![i as u32]),
1719 payload: ChunkPayload::Virtual(VirtualChunkRef {
1720 location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1721 offset: i as u64 * 1024,
1722 length: 1024,
1723 checksum: None,
1724 }),
1725 })
1726 .collect();
1727
1728 let manifest = Manifest::from_iter(
1729 &ManifestId::random(),
1730 long_chunks.clone(),
1731 Some(&config),
1732 )
1733 .await?
1734 .unwrap();
1735
1736 let root = manifest.root();
1737 assert!(root.location_dictionary().is_some());
1738 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1739 for am in root.arrays().iter() {
1740 for r in am.refs().iter() {
1741 assert!(r.compressed_location().is_some());
1742 assert!(r.location().is_none());
1743 }
1744 }
1745
1746 for chunk in &long_chunks {
1747 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1748 assert_eq!(payload, chunk.payload);
1749 }
1750 Ok(())
1751 }
1752
1753 #[tokio_test]
1754 async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1755 let node = NodeId::random();
1758 let chunks: Vec<ChunkInfo> = (0..100)
1759 .map(|i| ChunkInfo {
1760 node: node.clone(),
1761 coord: ChunkIndices(vec![i as u32]),
1762 payload: ChunkPayload::Virtual(VirtualChunkRef {
1763 location: VirtualChunkLocation::from_url(&format!(
1764 "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"
1765 ))
1766 .unwrap(),
1767 offset: i as u64 * 4096,
1768 length: 4096,
1769 checksum: None,
1770 }),
1771 })
1772 .collect();
1773
1774 let manifest_uncompressed =
1776 Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1777 .await?
1778 .unwrap();
1779 let size_uncompressed = manifest_uncompressed.bytes().len();
1780
1781 assert!(!manifest_uncompressed.uses_location_compression());
1782 assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1783
1784 let manifest_compressed = Manifest::from_iter(
1786 &ManifestId::random(),
1787 chunks.clone(),
1788 Some(&COMPRESS_CONFIG),
1789 )
1790 .await?
1791 .unwrap();
1792 let size_compressed = manifest_compressed.bytes().len();
1793
1794 assert!(manifest_compressed.uses_location_compression());
1795 assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1796 assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1797
1798 let root = manifest_compressed.root();
1800 for am in root.arrays().iter() {
1801 for r in am.refs().iter() {
1802 let compressed = r.compressed_location().unwrap();
1803 assert!(
1804 compressed.len() < 40,
1805 "compressed_location ({} bytes) should be under 40 bytes \
1806 (raw location is 118 bytes)",
1807 compressed.len(),
1808 );
1809 }
1810 }
1811
1812 assert!(
1814 size_compressed < size_uncompressed,
1815 "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1816 );
1817
1818 let saving_pct =
1819 (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1820 assert!(
1821 saving_pct > 15.0,
1822 "expected at least 15% size reduction with redundant locations, \
1823 got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1824 );
1825
1826 for chunk in &chunks {
1828 let p1 = manifest_uncompressed
1829 .get_chunk_payload(&chunk.node, &chunk.coord)
1830 .unwrap();
1831 let p2 =
1832 manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1833 assert_eq!(p1, chunk.payload);
1834 assert_eq!(p2, chunk.payload);
1835 }
1836
1837 Ok(())
1838 }
1839
1840 #[tokio_test]
1841 async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1842 let chunks = make_virtual_chunks(50);
1844 let manifest = Manifest::from_iter(
1845 &ManifestId::random(),
1846 chunks.clone(),
1847 Some(&COMPRESS_CONFIG),
1848 )
1849 .await?
1850 .unwrap();
1851
1852 let payloads: Vec<_> =
1853 manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1854 assert_eq!(payloads.len(), 50);
1855 for (i, payload) in payloads.iter().enumerate() {
1856 assert_eq!(payload, &chunks[i].payload);
1857 }
1858 Ok(())
1859 }
1860}