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("error fetching virtual reference")]
244 FetchError(#[source] Box<dyn std::error::Error + Send + Sync>),
245 #[error("the checksum of the object owning the virtual chunk has changed ({0})")]
246 ObjectModified(String),
247 #[error(
248 "error retrieving virtual chunk, not enough data. Expected: ({expected}), available ({available})"
249 )]
250 InvalidObjectSize { expected: u64, available: u64 },
251 #[error("azure store configuration must include an account")]
252 AzureConfigurationMustIncludeAccount,
253 #[error("decoding virtual chunk url")]
254 Decoding(#[from] FromUtf8Error),
255 #[error(
256 "no virtual chunk container named '{0}' found, check the repository configuration"
257 )]
258 NoContainerForName(String),
259 #[error("unknown error")]
260 OtherError(#[from] Box<dyn std::error::Error + Send + Sync>),
261}
262
263fn format_unauthorized_vcc(url_prefix: &str, name: &Option<String>) -> String {
264 let container = match name {
265 Some(n) => format!(" (container: {n})"),
266 None => String::new(),
267 };
268 format!(
269 "a virtual chunk in this repository resolves to the url prefix {url_prefix}{container}, \
270 to be able to fetch the chunk you need to authorize the virtual chunk container \
271 when you open/create the repository, see https://icechunk.io/en/stable/virtual/"
272 )
273}
274
275pub type VirtualReferenceError = ICError<VirtualReferenceErrorKind>;
276
277pub const VCC_RELATIVE_URL_SCHEME: &str = "vcc://";
278
279#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
280pub struct VirtualChunkLocation(String);
281
282impl VirtualChunkLocation {
283 pub fn url(&self) -> &str {
284 self.0.as_str()
285 }
286
287 fn from_trusted(s: String) -> Self {
289 VirtualChunkLocation(s)
290 }
291
292 pub fn is_relative(&self) -> bool {
294 self.0.starts_with(VCC_RELATIVE_URL_SCHEME)
295 }
296
297 pub fn parse_vcc(&self) -> Option<(&str, &str)> {
299 let rest = self.0.strip_prefix(VCC_RELATIVE_URL_SCHEME)?;
300 let slash = rest.find('/')?;
301 Some((&rest[..slash], &rest[slash + 1..]))
302 }
303
304 pub fn from_vcc_path(
306 container_name: &str,
307 relative_path: &str,
308 ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
309 if container_name.is_empty() || container_name.contains('/') {
310 return Err(VirtualReferenceError::capture(
311 VirtualReferenceErrorKind::NoContainerForName(container_name.to_string()),
312 ));
313 }
314 let mut result = String::with_capacity(
315 VCC_RELATIVE_URL_SCHEME.len()
316 + container_name.len()
317 + 1
318 + relative_path.len(),
319 );
320 result.push_str(VCC_RELATIVE_URL_SCHEME);
321 result.push_str(container_name);
322 for segment in relative_path.split('/').filter(|s| !s.is_empty()) {
323 result.push('/');
324 result.push_str(segment);
325 }
326 Ok(VirtualChunkLocation(result))
327 }
328
329 pub fn from_url(path: &str) -> Result<VirtualChunkLocation, VirtualReferenceError> {
331 Self::from_absolute_path(path)
334 }
335
336 fn from_absolute_path(
337 path: &str,
338 ) -> Result<VirtualChunkLocation, VirtualReferenceError> {
339 let url = url::Url::parse(path)
342 .map_err(|e| VirtualReferenceErrorKind::CannotParseUrl {
343 cause: e,
344 url: path.to_string(),
345 })
346 .capture()?;
347 let scheme = url.scheme();
348 let segments = url
349 .path_segments()
350 .ok_or_else(|| VirtualReferenceErrorKind::NoPathSegments(path.into()))
351 .capture()?;
352
353 let host = if let Some(host) = url.host_str() {
354 host
355 } else if scheme == "file" {
356 ""
357 } else if scheme == "vcc" {
358 return Err(VirtualReferenceError::capture(
359 VirtualReferenceErrorKind::NoContainerForName(path.into()),
360 ));
361 } else {
362 return Err(VirtualReferenceError::capture(
363 VirtualReferenceErrorKind::CannotParseBucketName(path.into()),
364 ));
365 };
366
367 let mut result = String::with_capacity(path.len());
368 result.push_str(scheme);
369 result.push_str("://");
370 result.push_str(host);
371 result.push('/');
372 let mut sep = "";
373 for segment in segments.filter(|x| !x.is_empty()) {
374 result.push_str(sep);
375 result.push_str(segment);
376 sep = "/";
377 }
378
379 Ok(VirtualChunkLocation(result))
380 }
381}
382
383#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
384pub struct SecondsSinceEpoch(pub u32);
385
386#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
387pub enum Checksum {
388 LastModified(SecondsSinceEpoch),
389 ETag(ETag),
390}
391
392#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
393pub struct VirtualChunkRef {
394 pub location: VirtualChunkLocation,
395 pub offset: ChunkOffset,
396 pub length: ChunkLength,
397 pub checksum: Option<Checksum>,
398}
399
400#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
401pub struct ChunkRef {
402 pub id: ChunkId,
403 pub offset: ChunkOffset,
404 pub length: ChunkLength,
405}
406
407#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
408#[non_exhaustive]
409pub enum ChunkPayload {
410 Inline(Bytes),
411 Virtual(VirtualChunkRef),
412 Ref(ChunkRef),
413}
414
415#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
416pub struct ChunkInfo {
417 pub node: NodeId,
418 pub coord: ChunkIndices,
419 pub payload: ChunkPayload,
420}
421
422const COMPRESSION_ALG_NONE: u8 = 0;
423const COMPRESSION_ALG_ZSTD_DICT: u8 = 1;
424const MAX_DECOMPRESSED_LOCATION_SIZE: usize = 1_024;
426
427#[derive(PartialEq)]
428pub struct Manifest {
429 buffer: Vec<u8>,
430}
431
432impl std::fmt::Debug for Manifest {
433 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434 f.debug_struct("Manifest")
435 .field("id", &self.id())
436 .field("chunks", &self.len())
437 .finish_non_exhaustive()
438 }
439}
440
441impl Manifest {
442 pub fn id(&self) -> ManifestId {
443 ManifestId::new(self.root().id().0)
444 }
445
446 pub fn bytes(&self) -> &[u8] {
447 self.buffer.as_slice()
448 }
449
450 pub fn from_buffer(buffer: Vec<u8>) -> Result<Manifest, IcechunkFormatError> {
451 let _ = flatbuffers::root_with_opts::<generated::Manifest<'_>>(
452 &ROOT_OPTIONS,
453 buffer.as_slice(),
454 )
455 .capture()?;
456 Ok(Manifest { buffer })
457 }
458
459 fn decompressor(
461 &self,
462 ) -> Result<Option<zstd::bulk::Decompressor<'static>>, IcechunkFormatError> {
463 let root = self.root();
464 if root.compression_algorithm() != COMPRESSION_ALG_ZSTD_DICT {
465 return Ok(None);
466 }
467 match root.location_dictionary() {
468 Some(dict_bytes) => {
469 let decompressor =
470 zstd::bulk::Decompressor::with_dictionary(dict_bytes.bytes())
471 .capture()?;
472 Ok(Some(decompressor))
473 }
474 None => Ok(None),
475 }
476 }
477
478 pub fn from_sorted_vec(
479 manifest_id: &ManifestId,
480 sorted_chunks: Vec<ChunkInfo>,
481 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
482 ) -> IcechunkResult<Option<Self>> {
483 let location_compression_dict =
484 train_location_dictionary(&sorted_chunks, virtual_chunks_compression_config)?;
485 let compressed_locations =
487 match (&location_compression_dict, virtual_chunks_compression_config) {
488 (Some(d), Some(config)) => compress_locations(&sorted_chunks, d, config),
489 _ => vec![None; sorted_chunks.len()],
490 };
491
492 let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024 * 1024);
495
496 let len = sorted_chunks.len();
497 let mut all = sorted_chunks.into_iter().zip(compressed_locations).peekable();
498
499 let mut array_manifests = Vec::with_capacity(1);
500 while let Some(current_node) = all.peek().map(|(chunk, _)| chunk.node.clone()) {
501 let mut refs = Vec::with_capacity(len);
503 while let Some((chunk, precompressed)) =
504 all.next_if(|(chunk, _)| chunk.node == current_node)
505 {
506 refs.push(mk_chunk_ref(&mut builder, chunk, precompressed));
507 }
508
509 let node_id = Some(generated::ObjectId8::new(¤t_node.0));
510 let refs = Some(builder.create_vector(refs.as_slice()));
511 let array_manifest = generated::ArrayManifest::create(
512 &mut builder,
513 &generated::ArrayManifestArgs {
514 node_id: node_id.as_ref(),
515 refs,
516 ..Default::default()
517 },
518 );
519 array_manifests.push(array_manifest);
520 }
521
522 if array_manifests.is_empty() {
523 return Ok(None);
525 }
526
527 let arrays = builder.create_vector(array_manifests.as_slice());
528 let bin_manifest_id = generated::ObjectId12::new(&manifest_id.0);
529
530 let (location_dictionary, compression_algorithm) =
531 if let Some(ref dict) = location_compression_dict {
532 (Some(builder.create_vector(dict.as_slice())), COMPRESSION_ALG_ZSTD_DICT)
533 } else {
534 (None, COMPRESSION_ALG_NONE)
535 };
536
537 let manifest = generated::Manifest::create(
538 &mut builder,
539 &generated::ManifestArgs {
540 id: Some(&bin_manifest_id),
541 arrays: Some(arrays),
542 location_dictionary,
543 compression_algorithm,
544 ..Default::default()
545 },
546 );
547
548 builder.finish(manifest, Some("Ichk"));
549 let (mut buffer, offset) = builder.collapse();
550 buffer.drain(0..offset);
551 buffer.shrink_to_fit();
552 Ok(Some(Manifest { buffer }))
553 }
554
555 pub async fn from_stream<E>(
556 manifest_id: &ManifestId,
557 stream: impl Stream<Item = Result<ChunkInfo, E>>,
558 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
559 ) -> Result<Option<Self>, E>
560 where
561 E: From<IcechunkFormatError>,
562 {
563 let mut all = stream.try_collect::<Vec<_>>().await?;
564 all.sort_by(|a, b| (&a.node, &a.coord).cmp(&(&b.node, &b.coord)));
565 Ok(Self::from_sorted_vec(manifest_id, all, virtual_chunks_compression_config)?)
566 }
567
568 pub async fn from_iter<T: IntoIterator<Item = ChunkInfo>>(
570 manifest_id: &ManifestId,
571 iter: T,
572 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
573 ) -> IcechunkResult<Option<Self>> {
574 Self::from_stream(
575 manifest_id,
576 futures::stream::iter(iter.into_iter().map(Ok::<_, IcechunkFormatError>)),
577 virtual_chunks_compression_config,
578 )
579 .await
580 }
581
582 pub fn len(&self) -> usize {
583 self.root().arrays().iter().map(|am| am.refs().len()).sum()
584 }
585
586 #[must_use]
587 pub fn is_empty(&self) -> bool {
588 self.len() == 0
589 }
590
591 #[expect(unsafe_code)]
592 fn root(&self) -> generated::Manifest<'_> {
593 unsafe { flatbuffers::root_unchecked::<generated::Manifest<'_>>(&self.buffer) }
597 }
598
599 pub fn arrays(&self) -> impl Iterator<Item = NodeId> {
600 self.root().arrays().iter().map(|am| NodeId::from(am.node_id().0))
601 }
602
603 pub fn uses_location_compression(&self) -> bool {
604 self.root().compression_algorithm() != COMPRESSION_ALG_NONE
605 }
606
607 pub fn location_dictionary_size(&self) -> Option<usize> {
608 self.root().location_dictionary().map(|d| d.len())
609 }
610
611 pub fn num_compressed_refs(&self) -> usize {
612 self.root()
613 .arrays()
614 .iter()
615 .flat_map(|am| am.refs().iter())
616 .filter(|r| r.compressed_location().is_some())
617 .count()
618 }
619
620 pub fn get_chunk_payload(
621 &self,
622 node: &NodeId,
623 coord: &ChunkIndices,
624 ) -> IcechunkResult<ChunkPayload> {
625 let mut decompressor = self.decompressor()?;
626 let manifest = self.root();
627 let chunk_ref = lookup_node(manifest, node)
628 .and_then(|array_manifest| lookup_ref(array_manifest, coord))
629 .ok_or_else(|| IcechunkFormatErrorKind::ChunkCoordinatesNotFound {
630 coords: coord.clone(),
631 })
632 .capture()?;
633 ref_to_payload(chunk_ref, decompressor.as_mut())
634 }
635
636 pub fn iter(
637 self: Arc<Self>,
638 node: NodeId,
639 ) -> Result<
640 impl Iterator<Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>>,
641 IcechunkFormatError,
642 > {
643 PayloadIterator::new(self, node)
644 }
645
646 pub fn chunk_payloads(
647 &self,
648 ) -> Result<
649 impl Iterator<Item = Result<ChunkPayload, IcechunkFormatError>> + '_,
650 IcechunkFormatError,
651 > {
652 let mut decompressor = self.decompressor()?;
653 let refs: Vec<_> =
654 self.root().arrays().iter().flat_map(|am| am.refs().iter()).collect();
655 Ok(refs.into_iter().map(move |r| ref_to_payload(r, decompressor.as_mut())))
656 }
657}
658
659fn lookup_node<'a>(
660 manifest: generated::Manifest<'a>,
661 node: &NodeId,
662) -> Option<generated::ArrayManifest<'a>> {
663 manifest.arrays().lookup_by_key(node.0, |am, id| am.node_id().0.cmp(id))
664}
665
666fn lookup_ref<'a>(
667 array_manifest: generated::ArrayManifest<'a>,
668 coord: &ChunkIndices,
669) -> Option<generated::ChunkRef<'a>> {
670 array_manifest.refs().lookup_by_key(coord.0.as_slice(), |chunk_ref, coords| {
671 chunk_ref.index().iter().cmp(coords.iter().copied())
672 })
673}
674
675pub struct PayloadIterator {
676 manifest: Arc<Manifest>,
677 node_id: NodeId,
678 last_ref_index: usize,
679 decompressor: Option<zstd::bulk::Decompressor<'static>>,
680}
681
682impl std::fmt::Debug for PayloadIterator {
683 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684 f.debug_struct("PayloadIterator")
685 .field("node_id", &self.node_id)
686 .field("last_ref_index", &self.last_ref_index)
687 .finish_non_exhaustive()
688 }
689}
690
691impl PayloadIterator {
692 fn new(
693 manifest: Arc<Manifest>,
694 node_id: NodeId,
695 ) -> Result<Self, IcechunkFormatError> {
696 let decompressor = manifest.decompressor()?;
697 Ok(Self { manifest, node_id, last_ref_index: 0, decompressor })
698 }
699}
700
701impl Iterator for PayloadIterator {
702 type Item = Result<(ChunkIndices, ChunkPayload), IcechunkFormatError>;
703
704 fn next(&mut self) -> Option<Self::Item> {
705 let manifest = self.manifest.root();
706 lookup_node(manifest, &self.node_id).and_then(|array_manifest| {
707 let refs = array_manifest.refs();
708 if self.last_ref_index >= refs.len() {
709 return None;
710 }
711
712 let chunk_ref = refs.get(self.last_ref_index);
713 self.last_ref_index += 1;
714 Some(
715 ref_to_payload(chunk_ref, self.decompressor.as_mut())
716 .map(|payl| (ChunkIndices(chunk_ref.index().iter().collect()), payl)),
717 )
718 })
719 }
720}
721
722fn ref_to_payload(
723 chunk_ref: generated::ChunkRef<'_>,
724 decompressor: Option<&mut zstd::bulk::Decompressor<'static>>,
725) -> Result<ChunkPayload, IcechunkFormatError> {
726 if let Some(chunk_id) = chunk_ref.chunk_id() {
727 let id = ChunkId::new(chunk_id.0);
728 Ok(ChunkPayload::Ref(ChunkRef {
729 id,
730 offset: chunk_ref.offset(),
731 length: chunk_ref.length(),
732 }))
733 } else if let Some(compressed) = chunk_ref.compressed_location() {
734 let decompressor = decompressor
735 .ok_or(IcechunkFormatErrorKind::MissingLocationCompressionDictionary)
736 .capture()?;
737 let decompressed = decompressor
738 .decompress(compressed.bytes(), MAX_DECOMPRESSED_LOCATION_SIZE)
739 .capture()?;
740 let location_string = String::from_utf8(decompressed)
741 .map_err(|e| {
742 IcechunkFormatErrorKind::IO(std::io::Error::new(
743 std::io::ErrorKind::InvalidData,
744 e,
745 ))
746 })
747 .capture()?;
748 let location = VirtualChunkLocation::from_trusted(location_string);
749 Ok(ChunkPayload::Virtual(VirtualChunkRef {
750 location,
751 checksum: checksum(&chunk_ref),
752 offset: chunk_ref.offset(),
753 length: chunk_ref.length(),
754 }))
755 } else if let Some(location) = chunk_ref.location() {
756 let location = VirtualChunkLocation::from_trusted(location.to_string());
757 Ok(ChunkPayload::Virtual(VirtualChunkRef {
758 location,
759 checksum: checksum(&chunk_ref),
760 offset: chunk_ref.offset(),
761 length: chunk_ref.length(),
762 }))
763 } else if let Some(data) = chunk_ref.inline() {
764 Ok(ChunkPayload::Inline(Bytes::copy_from_slice(data.bytes())))
765 } else {
766 Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
767 flatbuffers::InvalidFlatbuffer::InconsistentUnion {
768 field: Cow::Borrowed("chunk_id+location+inline"),
769 field_type: Cow::Borrowed("invalid"),
770 error_trace: Default::default(),
771 },
772 ))
773 .capture()
774 }
775}
776
777fn checksum(payload: &generated::ChunkRef<'_>) -> Option<Checksum> {
778 if let Some(etag) = payload.checksum_etag() {
779 Some(Checksum::ETag(ETag(etag.to_string())))
780 } else if payload.checksum_last_modified() > 0 {
781 Some(Checksum::LastModified(SecondsSinceEpoch(payload.checksum_last_modified())))
782 } else {
783 None
784 }
785}
786
787fn train_location_dictionary(
796 chunks: &[ChunkInfo],
797 virtual_chunks_compression_config: Option<&LocationCompressionConfig>,
798) -> IcechunkResult<Option<Vec<u8>>> {
799 let Some(config) = virtual_chunks_compression_config else {
800 return Ok(None);
801 };
802 let max_samples = config.dictionary_max_training_samples as usize;
803 let min_chunks = config.min_num_chunks as usize;
804 let max_dict_size = config.dictionary_max_size_bytes as usize;
805
806 let mut virtual_count: usize = 0;
807 let mut reservoir: Vec<&str> = Vec::with_capacity(max_samples);
808 let mut rng: SmallRng = rand::make_rng();
809
810 if chunks.len() < min_chunks {
811 return Ok(None);
812 }
813
814 for chunk in chunks {
815 if let ChunkPayload::Virtual(vref) = &chunk.payload {
816 let loc = vref.location.url();
817 if virtual_count < max_samples {
818 reservoir.push(loc);
820 } else {
821 let j = rng.random_range(0..=virtual_count);
823 if j < max_samples {
824 reservoir[j] = loc;
825 }
826 }
827 virtual_count += 1;
828 }
829 }
830
831 if virtual_count < min_chunks {
832 return Ok(None);
833 }
834
835 let sample_bytes: Vec<&[u8]> = reservoir.iter().map(|s| s.as_bytes()).collect();
836
837 let small_count = sample_bytes.iter().filter(|s| s.len() < 8).count();
839 if small_count >= sample_bytes.len() / 2 {
840 tracing::warn!(
841 "Skipping virtual chunk location compression: at least half of the {} samples are smaller than 8 bytes",
842 sample_bytes.len()
843 );
844 return Ok(None);
845 }
846
847 let mut sample_data: Vec<u8> =
848 sample_bytes.iter().flat_map(|s| s.iter().copied()).collect();
849 let mut sample_sizes: Vec<usize> = sample_bytes.iter().map(|s| s.len()).collect();
850 let total_sample_size = sample_data.len();
851
852 if total_sample_size > 0 && total_sample_size < max_dict_size {
854 let repeats = (max_dict_size / total_sample_size) + 1;
855 let original_data = sample_data.clone();
856 let original_sizes = sample_sizes.clone();
857 for _ in 0..repeats {
858 sample_data.extend_from_slice(&original_data);
859 sample_sizes.extend_from_slice(&original_sizes);
860 }
861 }
862
863 Ok(Some(
864 zstd::dict::from_continuous(&sample_data, &sample_sizes, max_dict_size)
865 .capture()?,
866 ))
867}
868
869fn compress_locations(
874 chunks: &[ChunkInfo],
875 dict: &[u8],
876 config: &LocationCompressionConfig,
877) -> Vec<Option<Vec<u8>>> {
878 let compression_level = config.compression_level;
879
880 #[cfg(not(target_family = "wasm"))]
881 {
882 let num_threads =
883 std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
884 let slice_size = chunks.len().div_ceil(num_threads);
885
886 std::thread::scope(|s| {
887 let handles: Vec<_> = chunks
888 .chunks(slice_size)
889 .map(|slice| {
890 s.spawn(|| {
891 let mut comp = zstd::bulk::Compressor::with_dictionary(
892 compression_level,
893 dict,
894 )
895 .ok();
896 slice
897 .iter()
898 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
899 (ChunkPayload::Virtual(vref), Some(comp)) => {
900 comp.compress(vref.location.url().as_bytes()).ok()
901 }
902 _ => None,
903 })
904 .collect::<Vec<_>>()
905 })
906 })
907 .collect();
908
909 #[expect(clippy::expect_used)]
910 handles
911 .into_iter()
912 .flat_map(|h| {
913 h.join()
914 .expect("Cannot join threads compressing virtual chunk locations")
915 })
916 .collect()
917 })
918 }
919
920 #[cfg(target_family = "wasm")]
921 {
922 let mut comp =
923 zstd::bulk::Compressor::with_dictionary(compression_level, dict).ok();
924 chunks
925 .iter()
926 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
927 (ChunkPayload::Virtual(vref), Some(comp)) => {
928 comp.compress(vref.location.url().as_bytes()).ok()
929 }
930 _ => None,
931 })
932 .collect()
933 }
934}
935
936fn mk_chunk_ref<'bldr>(
937 builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
938 chunk: ChunkInfo,
939 precompressed_location: Option<Vec<u8>>,
940) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
941 let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
942 match chunk.payload {
943 ChunkPayload::Inline(bytes) => {
944 let bytes = builder.create_vector(bytes.as_ref());
945 let args = generated::ChunkRefArgs {
946 inline: Some(bytes),
947 index,
948 ..Default::default()
949 };
950 generated::ChunkRef::create(builder, &args)
951 }
952 ChunkPayload::Virtual(virtual_chunk_ref) => {
953 let location_str = virtual_chunk_ref.location.0.as_str();
954 let (location, compressed_location) =
955 if let Some(compressed) = precompressed_location {
956 (None, Some(builder.create_vector(&compressed)))
957 } else {
958 (Some(builder.create_string(location_str)), None)
959 };
960 let args = generated::ChunkRefArgs {
961 index,
962 location,
963 compressed_location,
964 offset: virtual_chunk_ref.offset,
965 length: virtual_chunk_ref.length,
966 checksum_etag: match &virtual_chunk_ref.checksum {
967 Some(cs) => match cs {
968 Checksum::LastModified(_) => None,
969 Checksum::ETag(etag) => {
970 Some(builder.create_string(etag.0.as_str()))
971 }
972 },
973 None => None,
974 },
975 checksum_last_modified: match &virtual_chunk_ref.checksum {
976 Some(cs) => match cs {
977 Checksum::LastModified(seconds) => seconds.0,
978 Checksum::ETag(_) => 0,
979 },
980 None => 0,
981 },
982 ..Default::default()
983 };
984 generated::ChunkRef::create(builder, &args)
985 }
986 ChunkPayload::Ref(chunk_ref) => {
987 let id = generated::ObjectId12::new(&chunk_ref.id.0);
988 let args = generated::ChunkRefArgs {
989 index,
990 offset: chunk_ref.offset,
991 length: chunk_ref.length,
992 chunk_id: Some(&id),
993 ..Default::default()
994 };
995 generated::ChunkRef::create(builder, &args)
996 }
997 }
998}
999
1000static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
1001 max_depth: 64,
1002 max_tables: 500_000_000,
1003 max_apparent_size: 1 << 31, ignore_missing_null_terminator: true,
1005};
1006
1007#[cfg(test)]
1008#[expect(unused_qualifications)] mod tests {
1010 use super::*;
1011 use crate::roundtrip_serialization_tests;
1012 use crate::strategies::{
1013 ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
1014 manifest_splits, shapes_and_dims,
1015 };
1016 use icechunk_macros::{self, tokio_test};
1017 use itertools::{all, multizip};
1018 use proptest::collection::vec;
1019 use proptest::prelude::*;
1020 use std::error::Error;
1021 use test_strategy::proptest as alt_proptest;
1022
1023 roundtrip_serialization_tests!(
1024 serialize_and_deserialize_manifest_ref - manifest_ref,
1025 serialize_and_deserialize_manifest_splits - manifest_splits
1026 );
1027
1028 #[alt_proptest]
1029 fn test_property_extents_set_ops_same(
1030 #[strategy(manifest_extents(4))] e: ManifestExtents,
1031 ) {
1032 prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1033 prop_assert_eq!(e.union(&e), e.clone());
1034 prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1035 }
1036
1037 #[alt_proptest]
1038 fn test_property_extents_set_ops(
1039 #[strategy(manifest_extents(4))] e1: ManifestExtents,
1040 #[strategy(manifest_extents(4))] e2: ManifestExtents,
1041 ) {
1042 let union = e1.union(&e2);
1043 let intersection = e1.intersection(&e2);
1044
1045 prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1046 prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1047 prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1048 prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1049
1050 prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1052 prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1053
1054 if intersection.is_some() {
1055 let int = intersection.unwrap();
1056 let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1057 prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1058 prop_assert_eq!(int.overlap_with(&e2), expected);
1059 } else {
1060 prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1061 prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1062 }
1063 }
1064
1065 #[alt_proptest]
1066 fn test_property_extents_widths(
1067 #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1068 #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1069 #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1070 ) {
1071 let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1072 let extent2 = ManifestExtents::from_ranges_iter(
1073 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1074 |(extent, dleft, dright)| {
1075 ((extent.start as i32 + dleft) as u32)
1076 ..((extent.end as i32 + dright) as u32)
1077 },
1078 ),
1079 );
1080
1081 if all(delta_left.iter(), |elem| elem == &0i32)
1082 && all(delta_right.iter(), |elem| elem == &0i32)
1083 {
1084 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1085 }
1086
1087 let extent2 = ManifestExtents::from_ranges_iter(
1088 multizip((
1089 extent1.iter(),
1090 widths.iter(),
1091 delta_left.iter(),
1092 delta_right.iter(),
1093 ))
1094 .map(|(extent, width, dleft, dright)| {
1095 let (low, high) = (dleft.min(dright), dleft.max(dright));
1096 ((extent.start as i32 + width + low) as u32)
1097 ..((extent.end as i32 + width + high) as u32)
1098 }),
1099 );
1100
1101 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1102
1103 let extent2 = ManifestExtents::from_ranges_iter(
1104 multizip((
1105 extent1.iter(),
1106 widths.iter(),
1107 delta_left.iter(),
1108 delta_right.iter(),
1109 ))
1110 .map(|(extent, width, dleft, dright)| {
1111 let (low, high) = (dleft.min(dright), dleft.max(dright));
1112 ((extent.start as i32 - width - high).max(0i32) as u32)
1113 ..((extent.end as i32 - width - low) as u32)
1114 }),
1115 );
1116
1117 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1118
1119 let extent2 = ManifestExtents::from_ranges_iter(
1120 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1121 |(extent, dleft, dright)| {
1122 ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1123 ..((extent.end as i32 + dright + 1) as u32)
1124 },
1125 ),
1126 );
1127 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1128 }
1129
1130 #[icechunk_macros::test]
1131 fn test_overlaps() -> Result<(), Box<dyn Error>> {
1132 let e1 = ManifestExtents::new(
1133 vec![0u32, 1, 2].as_slice(),
1134 vec![2u32, 4, 6].as_slice(),
1135 );
1136
1137 let e2 = ManifestExtents::new(
1138 vec![10u32, 1, 2].as_slice(),
1139 vec![12u32, 4, 6].as_slice(),
1140 );
1141
1142 let union = ManifestExtents::new(
1143 vec![0u32, 1, 2].as_slice(),
1144 vec![12u32, 4, 6].as_slice(),
1145 );
1146
1147 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1148 assert_eq!(e1.intersection(&e2), None);
1149 assert_eq!(e1.union(&e2), union);
1150
1151 let e1 = ManifestExtents::new(
1152 vec![0u32, 1, 2].as_slice(),
1153 vec![2u32, 4, 6].as_slice(),
1154 );
1155 let e2 = ManifestExtents::new(
1156 vec![2u32, 1, 2].as_slice(),
1157 vec![42u32, 4, 6].as_slice(),
1158 );
1159 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1160 assert_eq!(e1.overlap_with(&e2), Overlap::None);
1161
1162 let e1 = ManifestExtents::new(
1164 vec![0u32, 1, 2].as_slice(),
1165 vec![3u32, 4, 6].as_slice(),
1166 );
1167 let e2 = ManifestExtents::new(
1168 vec![2u32, 1, 2].as_slice(),
1169 vec![3u32, 4, 6].as_slice(),
1170 );
1171 let union = ManifestExtents::new(
1172 vec![0u32, 1, 2].as_slice(),
1173 vec![3u32, 4, 6].as_slice(),
1174 );
1175 let intersection = ManifestExtents::new(
1176 vec![2u32, 1, 2].as_slice(),
1177 vec![3u32, 4, 6].as_slice(),
1178 );
1179 assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1180 assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1181 assert_eq!(e1.union(&e2), union.clone());
1182 assert_eq!(e2.union(&e1), union.clone());
1183 assert_eq!(e1.intersection(&e2), Some(intersection));
1184
1185 let e1 = ManifestExtents::new(
1187 vec![0u32, 1, 2].as_slice(),
1188 vec![3u32, 4, 6].as_slice(),
1189 );
1190 let e2 = ManifestExtents::new(
1191 vec![2u32, 1, 2].as_slice(),
1192 vec![2u32, 4, 6].as_slice(),
1193 );
1194 assert_eq!(e1.intersection(&e2), None);
1195
1196 let splits = ManifestSplits::from_edges(vec![
1198 vec![0, 10, 20],
1199 vec![0, 1, 2],
1200 vec![0, 21, 22],
1201 ]);
1202 for vec in splits.iter().combinations(2) {
1203 assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1204 assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1205 }
1206
1207 Ok(())
1208 }
1209
1210 #[alt_proptest]
1211 fn test_manifest_split_from_edges(
1212 #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1213 ) {
1214 let ShapeDim { shape, .. } = shape_dim;
1216
1217 let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1218 let split_shape: Vec<u64> = shape
1219 .iter()
1220 .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1221 .collect();
1222
1223 let ndim = shape.len();
1224 let edges: Vec<Vec<u32>> = (0usize..ndim)
1225 .map(|axis| {
1226 uniform_manifest_split_edges(
1227 num_chunks[axis],
1228 &(split_shape[axis] as u32),
1229 )
1230 })
1231 .collect();
1232
1233 let splits = ManifestSplits::from_edges(edges.into_iter());
1234 for edge in splits.iter() {
1235 prop_assert_eq!(edge.len(), ndim);
1237 for range in edge.iter() {
1238 prop_assert!(range.end > range.start);
1239 }
1240 }
1241
1242 for edges in splits.iter().combinations(2) {
1244 let is_equal =
1245 zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1246 (range1.start == range2.start) && (range1.end == range2.end)
1247 });
1248 prop_assert!(!is_equal);
1249 }
1250 }
1251
1252 const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1253 min_num_chunks: 10,
1254 dictionary_max_training_samples: 500,
1255 dictionary_max_size_bytes: 16 * 1024,
1256 compression_level: 3,
1257 };
1258
1259 fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1260 let node = NodeId::random();
1261 (0..n)
1262 .map(|i| ChunkInfo {
1263 node: node.clone(),
1264 coord: ChunkIndices(vec![i as u32]),
1265 payload: ChunkPayload::Virtual(VirtualChunkRef {
1266 location: VirtualChunkLocation::from_url(&format!(
1267 "s3://my-bucket/path/to/data/chunk_{i:06}"
1268 ))
1269 .unwrap(),
1270 offset: i as u64 * 1024,
1271 length: 1024,
1272 checksum: None,
1273 }),
1274 })
1275 .collect()
1276 }
1277
1278 #[tokio_test]
1279 async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1280 let chunks = make_virtual_chunks(50);
1282 let manifest = Manifest::from_iter(
1283 &ManifestId::random(),
1284 chunks.clone(),
1285 Some(&COMPRESS_CONFIG),
1286 )
1287 .await?
1288 .unwrap();
1289
1290 let root = manifest.root();
1291 assert!(root.location_dictionary().is_some());
1292 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1293 for am in root.arrays().iter() {
1294 for r in am.refs().iter() {
1295 assert!(r.compressed_location().is_some());
1296 assert!(r.location().is_none());
1297 }
1298 }
1299
1300 for chunk in &chunks {
1301 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1302 assert_eq!(payload, chunk.payload);
1303 }
1304 Ok(())
1305 }
1306
1307 #[tokio_test]
1308 async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1309 let chunks = make_virtual_chunks(5);
1311 let manifest = Manifest::from_iter(
1312 &ManifestId::random(),
1313 chunks.clone(),
1314 Some(&COMPRESS_CONFIG),
1315 )
1316 .await?
1317 .unwrap();
1318
1319 let root = manifest.root();
1320 assert!(root.location_dictionary().is_none());
1321 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1322 for am in root.arrays().iter() {
1323 for r in am.refs().iter() {
1324 assert!(r.compressed_location().is_none());
1325 assert!(r.location().is_some());
1326 }
1327 }
1328
1329 for chunk in &chunks {
1330 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1331 assert_eq!(payload, chunk.payload);
1332 }
1333 Ok(())
1334 }
1335
1336 #[tokio_test]
1337 async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1338 let node = NodeId::random();
1340 let mut chunks: Vec<ChunkInfo> = (0..30)
1341 .map(|i| ChunkInfo {
1342 node: node.clone(),
1343 coord: ChunkIndices(vec![i]),
1344 payload: ChunkPayload::Virtual(VirtualChunkRef {
1345 location: VirtualChunkLocation::from_url(&format!(
1346 "s3://my-bucket/path/to/data/chunk_{i:06}"
1347 ))
1348 .unwrap(),
1349 offset: i as u64 * 1024,
1350 length: 1024,
1351 checksum: None,
1352 }),
1353 })
1354 .collect();
1355 chunks.push(ChunkInfo {
1356 node: node.clone(),
1357 coord: ChunkIndices(vec![100]),
1358 payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1359 });
1360 chunks.push(ChunkInfo {
1361 node: node.clone(),
1362 coord: ChunkIndices(vec![101]),
1363 payload: ChunkPayload::Ref(ChunkRef {
1364 id: ChunkId::random(),
1365 offset: 0,
1366 length: 512,
1367 }),
1368 });
1369
1370 let manifest = Manifest::from_iter(
1371 &ManifestId::random(),
1372 chunks.clone(),
1373 Some(&COMPRESS_CONFIG),
1374 )
1375 .await?
1376 .unwrap();
1377
1378 for chunk in &chunks {
1379 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1380 assert_eq!(payload, chunk.payload);
1381 }
1382 Ok(())
1383 }
1384
1385 #[tokio_test]
1386 async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1387 let chunks = make_virtual_chunks(50);
1389 let manifest = Manifest::from_iter(
1390 &ManifestId::random(),
1391 chunks.clone(),
1392 Some(&COMPRESS_CONFIG),
1393 )
1394 .await?
1395 .unwrap();
1396 let bytes = manifest.bytes().to_vec();
1397 let manifest2 = Manifest::from_buffer(bytes)?;
1398
1399 for chunk in &chunks {
1400 let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1401 assert_eq!(payload, chunk.payload);
1402 }
1403 Ok(())
1404 }
1405
1406 #[tokio_test]
1407 async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1408 let chunks = make_virtual_chunks(50);
1410 let node = chunks[0].node.clone();
1411 let manifest = Arc::new(
1412 Manifest::from_iter(
1413 &ManifestId::random(),
1414 chunks.clone(),
1415 Some(&COMPRESS_CONFIG),
1416 )
1417 .await?
1418 .unwrap(),
1419 );
1420
1421 let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1422 assert_eq!(iter_results.len(), 50);
1423 for (i, result) in iter_results.into_iter().enumerate() {
1424 let (coord, payload) = result?;
1425 assert_eq!(coord, chunks[i].coord);
1426 assert_eq!(payload, chunks[i].payload);
1427 }
1428 Ok(())
1429 }
1430
1431 #[tokio_test]
1432 async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1433 let chunks = make_virtual_chunks(50);
1435 let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1436 .await?
1437 .unwrap();
1438
1439 let root = manifest.root();
1440 assert!(root.location_dictionary().is_none());
1441 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1442
1443 for am in root.arrays().iter() {
1445 for r in am.refs().iter() {
1446 if r.chunk_id().is_none() && r.inline().is_none() {
1447 assert!(r.location().is_some(), "location should be set");
1448 assert!(
1449 r.compressed_location().is_none(),
1450 "compressed_location should be None"
1451 );
1452 }
1453 }
1454 }
1455
1456 for chunk in &chunks {
1458 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1459 assert_eq!(payload, chunk.payload);
1460 }
1461 Ok(())
1462 }
1463
1464 #[tokio_test]
1465 async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1466 let chunks = make_virtual_chunks(50);
1468 let manifest = Manifest::from_iter(
1469 &ManifestId::random(),
1470 chunks.clone(),
1471 Some(&COMPRESS_CONFIG),
1472 )
1473 .await?
1474 .unwrap();
1475
1476 let root = manifest.root();
1477 assert!(root.location_dictionary().is_some());
1478 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1479
1480 for am in root.arrays().iter() {
1482 for r in am.refs().iter() {
1483 if r.chunk_id().is_none() && r.inline().is_none() {
1484 assert!(
1485 r.compressed_location().is_some(),
1486 "compressed_location should be set"
1487 );
1488 assert!(
1489 r.location().is_none(),
1490 "location should be None for compressed chunks"
1491 );
1492 }
1493 }
1494 }
1495
1496 for chunk in &chunks {
1498 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1499 assert_eq!(payload, chunk.payload);
1500 }
1501 Ok(())
1502 }
1503
1504 #[tokio_test]
1505 async fn test_compression_skipped_when_locations_too_short()
1506 -> Result<(), Box<dyn Error>> {
1507 let config = LocationCompressionConfig {
1511 min_num_chunks: 2,
1512 dictionary_max_training_samples: 500,
1513 dictionary_max_size_bytes: 256,
1514 compression_level: 3,
1515 };
1516
1517 let node = NodeId::random();
1518 let short_chunks: Vec<ChunkInfo> = (0..2)
1520 .map(|i| ChunkInfo {
1521 node: node.clone(),
1522 coord: ChunkIndices(vec![i as u32]),
1523 payload: ChunkPayload::Virtual(VirtualChunkRef {
1524 location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1525 offset: i as u64 * 1024,
1526 length: 1024,
1527 checksum: None,
1528 }),
1529 })
1530 .collect();
1531
1532 let manifest = Manifest::from_iter(
1533 &ManifestId::random(),
1534 short_chunks.clone(),
1535 Some(&config),
1536 )
1537 .await?
1538 .unwrap();
1539
1540 let root = manifest.root();
1541 assert!(root.location_dictionary().is_none());
1542 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1543 for am in root.arrays().iter() {
1544 for r in am.refs().iter() {
1545 assert!(r.compressed_location().is_none());
1546 assert!(r.location().is_some());
1547 }
1548 }
1549
1550 for chunk in &short_chunks {
1551 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1552 assert_eq!(payload, chunk.payload);
1553 }
1554
1555 let long_chunks: Vec<ChunkInfo> = (0..2)
1557 .map(|i| ChunkInfo {
1558 node: node.clone(),
1559 coord: ChunkIndices(vec![i as u32]),
1560 payload: ChunkPayload::Virtual(VirtualChunkRef {
1561 location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1562 offset: i as u64 * 1024,
1563 length: 1024,
1564 checksum: None,
1565 }),
1566 })
1567 .collect();
1568
1569 let manifest = Manifest::from_iter(
1570 &ManifestId::random(),
1571 long_chunks.clone(),
1572 Some(&config),
1573 )
1574 .await?
1575 .unwrap();
1576
1577 let root = manifest.root();
1578 assert!(root.location_dictionary().is_some());
1579 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1580 for am in root.arrays().iter() {
1581 for r in am.refs().iter() {
1582 assert!(r.compressed_location().is_some());
1583 assert!(r.location().is_none());
1584 }
1585 }
1586
1587 for chunk in &long_chunks {
1588 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1589 assert_eq!(payload, chunk.payload);
1590 }
1591 Ok(())
1592 }
1593
1594 #[tokio_test]
1595 async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1596 let node = NodeId::random();
1599 let chunks: Vec<ChunkInfo> = (0..100)
1600 .map(|i| ChunkInfo {
1601 node: node.clone(),
1602 coord: ChunkIndices(vec![i as u32]),
1603 payload: ChunkPayload::Virtual(VirtualChunkRef {
1604 location: VirtualChunkLocation::from_url(&format!(
1605 "s3://my-very-long-bucket-name/some/deeply/nested/path/to/dataset/v1.2.3/year=2024/month=01/day=15/chunk_{i:06}.parquet"
1606 ))
1607 .unwrap(),
1608 offset: i as u64 * 4096,
1609 length: 4096,
1610 checksum: None,
1611 }),
1612 })
1613 .collect();
1614
1615 let manifest_uncompressed =
1617 Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1618 .await?
1619 .unwrap();
1620 let size_uncompressed = manifest_uncompressed.bytes().len();
1621
1622 assert!(!manifest_uncompressed.uses_location_compression());
1623 assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1624
1625 let manifest_compressed = Manifest::from_iter(
1627 &ManifestId::random(),
1628 chunks.clone(),
1629 Some(&COMPRESS_CONFIG),
1630 )
1631 .await?
1632 .unwrap();
1633 let size_compressed = manifest_compressed.bytes().len();
1634
1635 assert!(manifest_compressed.uses_location_compression());
1636 assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1637 assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1638
1639 let root = manifest_compressed.root();
1641 for am in root.arrays().iter() {
1642 for r in am.refs().iter() {
1643 let compressed = r.compressed_location().unwrap();
1644 assert!(
1645 compressed.len() < 40,
1646 "compressed_location ({} bytes) should be under 40 bytes \
1647 (raw location is 118 bytes)",
1648 compressed.len(),
1649 );
1650 }
1651 }
1652
1653 assert!(
1655 size_compressed < size_uncompressed,
1656 "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1657 );
1658
1659 let saving_pct =
1660 (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1661 assert!(
1662 saving_pct > 15.0,
1663 "expected at least 15% size reduction with redundant locations, \
1664 got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1665 );
1666
1667 for chunk in &chunks {
1669 let p1 = manifest_uncompressed
1670 .get_chunk_payload(&chunk.node, &chunk.coord)
1671 .unwrap();
1672 let p2 =
1673 manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1674 assert_eq!(p1, chunk.payload);
1675 assert_eq!(p2, chunk.payload);
1676 }
1677
1678 Ok(())
1679 }
1680
1681 #[tokio_test]
1682 async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1683 let chunks = make_virtual_chunks(50);
1685 let manifest = Manifest::from_iter(
1686 &ManifestId::random(),
1687 chunks.clone(),
1688 Some(&COMPRESS_CONFIG),
1689 )
1690 .await?
1691 .unwrap();
1692
1693 let payloads: Vec<_> =
1694 manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1695 assert_eq!(payloads.len(), 50);
1696 for (i, payload) in payloads.iter().enumerate() {
1697 assert_eq!(payload, &chunks[i].payload);
1698 }
1699 Ok(())
1700 }
1701}