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 let num_threads =
880 std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
881 let slice_size = chunks.len().div_ceil(num_threads);
882
883 std::thread::scope(|s| {
884 let handles: Vec<_> = chunks
885 .chunks(slice_size)
886 .map(|slice| {
887 s.spawn(|| {
888 let mut comp =
889 zstd::bulk::Compressor::with_dictionary(compression_level, dict)
890 .ok();
891 slice
892 .iter()
893 .map(|chunk| match (&chunk.payload, comp.as_mut()) {
894 (ChunkPayload::Virtual(vref), Some(comp)) => {
895 comp.compress(vref.location.url().as_bytes()).ok()
896 }
897 _ => None,
898 })
899 .collect::<Vec<_>>()
900 })
901 })
902 .collect();
903
904 #[expect(clippy::expect_used)]
905 handles
906 .into_iter()
907 .flat_map(|h| {
908 h.join().expect("Cannot join threads compressing virtual chunk locations")
909 })
910 .collect()
911 })
912}
913
914fn mk_chunk_ref<'bldr>(
915 builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
916 chunk: ChunkInfo,
917 precompressed_location: Option<Vec<u8>>,
918) -> flatbuffers::WIPOffset<generated::ChunkRef<'bldr>> {
919 let index = Some(builder.create_vector(chunk.coord.0.as_slice()));
920 match chunk.payload {
921 ChunkPayload::Inline(bytes) => {
922 let bytes = builder.create_vector(bytes.as_ref());
923 let args = generated::ChunkRefArgs {
924 inline: Some(bytes),
925 index,
926 ..Default::default()
927 };
928 generated::ChunkRef::create(builder, &args)
929 }
930 ChunkPayload::Virtual(virtual_chunk_ref) => {
931 let location_str = virtual_chunk_ref.location.0.as_str();
932 let (location, compressed_location) =
933 if let Some(compressed) = precompressed_location {
934 (None, Some(builder.create_vector(&compressed)))
935 } else {
936 (Some(builder.create_string(location_str)), None)
937 };
938 let args = generated::ChunkRefArgs {
939 index,
940 location,
941 compressed_location,
942 offset: virtual_chunk_ref.offset,
943 length: virtual_chunk_ref.length,
944 checksum_etag: match &virtual_chunk_ref.checksum {
945 Some(cs) => match cs {
946 Checksum::LastModified(_) => None,
947 Checksum::ETag(etag) => {
948 Some(builder.create_string(etag.0.as_str()))
949 }
950 },
951 None => None,
952 },
953 checksum_last_modified: match &virtual_chunk_ref.checksum {
954 Some(cs) => match cs {
955 Checksum::LastModified(seconds) => seconds.0,
956 Checksum::ETag(_) => 0,
957 },
958 None => 0,
959 },
960 ..Default::default()
961 };
962 generated::ChunkRef::create(builder, &args)
963 }
964 ChunkPayload::Ref(chunk_ref) => {
965 let id = generated::ObjectId12::new(&chunk_ref.id.0);
966 let args = generated::ChunkRefArgs {
967 index,
968 offset: chunk_ref.offset,
969 length: chunk_ref.length,
970 chunk_id: Some(&id),
971 ..Default::default()
972 };
973 generated::ChunkRef::create(builder, &args)
974 }
975 }
976}
977
978static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
979 max_depth: 64,
980 max_tables: 500_000_000,
981 max_apparent_size: 1 << 31, ignore_missing_null_terminator: true,
983};
984
985#[cfg(test)]
986#[expect(unused_qualifications)] mod tests {
988 use super::*;
989 use crate::roundtrip_serialization_tests;
990 use crate::strategies::{
991 ShapeDim, limited_width_manifest_extents, manifest_extents, manifest_ref,
992 manifest_splits, shapes_and_dims,
993 };
994 use icechunk_macros::{self, tokio_test};
995 use itertools::{all, multizip};
996 use proptest::collection::vec;
997 use proptest::prelude::*;
998 use std::error::Error;
999 use test_strategy::proptest as alt_proptest;
1000
1001 roundtrip_serialization_tests!(
1002 serialize_and_deserialize_manifest_ref - manifest_ref,
1003 serialize_and_deserialize_manifest_splits - manifest_splits
1004 );
1005
1006 #[alt_proptest]
1007 fn test_property_extents_set_ops_same(
1008 #[strategy(manifest_extents(4))] e: ManifestExtents,
1009 ) {
1010 prop_assert_eq!(e.intersection(&e), Some(e.clone()));
1011 prop_assert_eq!(e.union(&e), e.clone());
1012 prop_assert_eq!(e.overlap_with(&e), Overlap::Complete);
1013 }
1014
1015 #[alt_proptest]
1016 fn test_property_extents_set_ops(
1017 #[strategy(manifest_extents(4))] e1: ManifestExtents,
1018 #[strategy(manifest_extents(4))] e2: ManifestExtents,
1019 ) {
1020 let union = e1.union(&e2);
1021 let intersection = e1.intersection(&e2);
1022
1023 prop_assert_eq!(e1.intersection(&union), Some(e1.clone()));
1024 prop_assert_eq!(union.intersection(&e1), Some(e1.clone()));
1025 prop_assert_eq!(e2.intersection(&union), Some(e2.clone()));
1026 prop_assert_eq!(union.intersection(&e2), Some(e2.clone()));
1027
1028 prop_assert_eq!(e1.overlap_with(&union), Overlap::Complete);
1030 prop_assert_eq!(e2.overlap_with(&union), Overlap::Complete);
1031
1032 if intersection.is_some() {
1033 let int = intersection.unwrap();
1034 let expected = if e1 == e1 { Overlap::Complete } else { Overlap::Partial };
1035 prop_assert_eq!(int.overlap_with(&e1), expected.clone());
1036 prop_assert_eq!(int.overlap_with(&e2), expected);
1037 } else {
1038 prop_assert_eq!(e2.overlap_with(&e1), Overlap::None);
1039 prop_assert_eq!(e1.overlap_with(&e2), Overlap::None);
1040 }
1041 }
1042
1043 #[alt_proptest]
1044 fn test_property_extents_widths(
1045 #[strategy(limited_width_manifest_extents(4))] extent1: ManifestExtents,
1046 #[strategy(vec(0..100, 4))] delta_left: Vec<i32>,
1047 #[strategy(vec(0..100, 4))] delta_right: Vec<i32>,
1048 ) {
1049 let widths = extent1.iter().map(|r| (r.end - r.start) as i32).collect::<Vec<_>>();
1050 let extent2 = ManifestExtents::from_ranges_iter(
1051 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1052 |(extent, dleft, dright)| {
1053 ((extent.start as i32 + dleft) as u32)
1054 ..((extent.end as i32 + dright) as u32)
1055 },
1056 ),
1057 );
1058
1059 if all(delta_left.iter(), |elem| elem == &0i32)
1060 && all(delta_right.iter(), |elem| elem == &0i32)
1061 {
1062 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Complete);
1063 }
1064
1065 let extent2 = ManifestExtents::from_ranges_iter(
1066 multizip((
1067 extent1.iter(),
1068 widths.iter(),
1069 delta_left.iter(),
1070 delta_right.iter(),
1071 ))
1072 .map(|(extent, width, dleft, dright)| {
1073 let (low, high) = (dleft.min(dright), dleft.max(dright));
1074 ((extent.start as i32 + width + low) as u32)
1075 ..((extent.end as i32 + width + high) as u32)
1076 }),
1077 );
1078
1079 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1080
1081 let extent2 = ManifestExtents::from_ranges_iter(
1082 multizip((
1083 extent1.iter(),
1084 widths.iter(),
1085 delta_left.iter(),
1086 delta_right.iter(),
1087 ))
1088 .map(|(extent, width, dleft, dright)| {
1089 let (low, high) = (dleft.min(dright), dleft.max(dright));
1090 ((extent.start as i32 - width - high).max(0i32) as u32)
1091 ..((extent.end as i32 - width - low) as u32)
1092 }),
1093 );
1094
1095 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::None);
1096
1097 let extent2 = ManifestExtents::from_ranges_iter(
1098 multizip((extent1.iter(), delta_left.iter(), delta_right.iter())).map(
1099 |(extent, dleft, dright)| {
1100 ((extent.start as i32 - dleft - 1).max(0i32) as u32)
1101 ..((extent.end as i32 + dright + 1) as u32)
1102 },
1103 ),
1104 );
1105 prop_assert_eq!(extent2.overlap_with(&extent1), Overlap::Partial);
1106 }
1107
1108 #[icechunk_macros::test]
1109 fn test_overlaps() -> Result<(), Box<dyn Error>> {
1110 let e1 = ManifestExtents::new(
1111 vec![0u32, 1, 2].as_slice(),
1112 vec![2u32, 4, 6].as_slice(),
1113 );
1114
1115 let e2 = ManifestExtents::new(
1116 vec![10u32, 1, 2].as_slice(),
1117 vec![12u32, 4, 6].as_slice(),
1118 );
1119
1120 let union = ManifestExtents::new(
1121 vec![0u32, 1, 2].as_slice(),
1122 vec![12u32, 4, 6].as_slice(),
1123 );
1124
1125 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1126 assert_eq!(e1.intersection(&e2), None);
1127 assert_eq!(e1.union(&e2), union);
1128
1129 let e1 = ManifestExtents::new(
1130 vec![0u32, 1, 2].as_slice(),
1131 vec![2u32, 4, 6].as_slice(),
1132 );
1133 let e2 = ManifestExtents::new(
1134 vec![2u32, 1, 2].as_slice(),
1135 vec![42u32, 4, 6].as_slice(),
1136 );
1137 assert_eq!(e2.overlap_with(&e1), Overlap::None);
1138 assert_eq!(e1.overlap_with(&e2), Overlap::None);
1139
1140 let e1 = ManifestExtents::new(
1142 vec![0u32, 1, 2].as_slice(),
1143 vec![3u32, 4, 6].as_slice(),
1144 );
1145 let e2 = ManifestExtents::new(
1146 vec![2u32, 1, 2].as_slice(),
1147 vec![3u32, 4, 6].as_slice(),
1148 );
1149 let union = ManifestExtents::new(
1150 vec![0u32, 1, 2].as_slice(),
1151 vec![3u32, 4, 6].as_slice(),
1152 );
1153 let intersection = ManifestExtents::new(
1154 vec![2u32, 1, 2].as_slice(),
1155 vec![3u32, 4, 6].as_slice(),
1156 );
1157 assert_eq!(e2.overlap_with(&e1), Overlap::Complete);
1158 assert_eq!(e1.overlap_with(&e2), Overlap::Partial);
1159 assert_eq!(e1.union(&e2), union.clone());
1160 assert_eq!(e2.union(&e1), union.clone());
1161 assert_eq!(e1.intersection(&e2), Some(intersection));
1162
1163 let e1 = ManifestExtents::new(
1165 vec![0u32, 1, 2].as_slice(),
1166 vec![3u32, 4, 6].as_slice(),
1167 );
1168 let e2 = ManifestExtents::new(
1169 vec![2u32, 1, 2].as_slice(),
1170 vec![2u32, 4, 6].as_slice(),
1171 );
1172 assert_eq!(e1.intersection(&e2), None);
1173
1174 let splits = ManifestSplits::from_edges(vec![
1176 vec![0, 10, 20],
1177 vec![0, 1, 2],
1178 vec![0, 21, 22],
1179 ]);
1180 for vec in splits.iter().combinations(2) {
1181 assert_eq!(vec[0].overlap_with(&vec[1]), Overlap::None);
1182 assert_eq!(vec[1].overlap_with(&vec[0]), Overlap::None);
1183 }
1184
1185 Ok(())
1186 }
1187
1188 #[alt_proptest]
1189 fn test_manifest_split_from_edges(
1190 #[strategy(shapes_and_dims(Some(5), Some(1)))] shape_dim: ShapeDim,
1191 ) {
1192 let ShapeDim { shape, .. } = shape_dim;
1194
1195 let num_chunks: Vec<u32> = shape.iter().map(|x| x.num_chunks()).collect();
1196 let split_shape: Vec<u64> = shape
1197 .iter()
1198 .map(|x| x.array_length().div_ceil(x.num_chunks() as u64))
1199 .collect();
1200
1201 let ndim = shape.len();
1202 let edges: Vec<Vec<u32>> = (0usize..ndim)
1203 .map(|axis| {
1204 uniform_manifest_split_edges(
1205 num_chunks[axis],
1206 &(split_shape[axis] as u32),
1207 )
1208 })
1209 .collect();
1210
1211 let splits = ManifestSplits::from_edges(edges.into_iter());
1212 for edge in splits.iter() {
1213 prop_assert_eq!(edge.len(), ndim);
1215 for range in edge.iter() {
1216 prop_assert!(range.end > range.start);
1217 }
1218 }
1219
1220 for edges in splits.iter().combinations(2) {
1222 let is_equal =
1223 zip(edges[0].iter(), edges[1].iter()).all(|(range1, range2)| {
1224 (range1.start == range2.start) && (range1.end == range2.end)
1225 });
1226 prop_assert!(!is_equal);
1227 }
1228 }
1229
1230 const COMPRESS_CONFIG: LocationCompressionConfig = LocationCompressionConfig {
1231 min_num_chunks: 10,
1232 dictionary_max_training_samples: 500,
1233 dictionary_max_size_bytes: 16 * 1024,
1234 compression_level: 3,
1235 };
1236
1237 fn make_virtual_chunks(n: usize) -> Vec<ChunkInfo> {
1238 let node = NodeId::random();
1239 (0..n)
1240 .map(|i| ChunkInfo {
1241 node: node.clone(),
1242 coord: ChunkIndices(vec![i as u32]),
1243 payload: ChunkPayload::Virtual(VirtualChunkRef {
1244 location: VirtualChunkLocation::from_url(&format!(
1245 "s3://my-bucket/path/to/data/chunk_{i:06}"
1246 ))
1247 .unwrap(),
1248 offset: i as u64 * 1024,
1249 length: 1024,
1250 checksum: None,
1251 }),
1252 })
1253 .collect()
1254 }
1255
1256 #[tokio_test]
1257 async fn test_compression_round_trip() -> Result<(), Box<dyn Error>> {
1258 let chunks = make_virtual_chunks(50);
1260 let manifest = Manifest::from_iter(
1261 &ManifestId::random(),
1262 chunks.clone(),
1263 Some(&COMPRESS_CONFIG),
1264 )
1265 .await?
1266 .unwrap();
1267
1268 let root = manifest.root();
1269 assert!(root.location_dictionary().is_some());
1270 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1271 for am in root.arrays().iter() {
1272 for r in am.refs().iter() {
1273 assert!(r.compressed_location().is_some());
1274 assert!(r.location().is_none());
1275 }
1276 }
1277
1278 for chunk in &chunks {
1279 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1280 assert_eq!(payload, chunk.payload);
1281 }
1282 Ok(())
1283 }
1284
1285 #[tokio_test]
1286 async fn test_compression_below_threshold() -> Result<(), Box<dyn Error>> {
1287 let chunks = make_virtual_chunks(5);
1289 let manifest = Manifest::from_iter(
1290 &ManifestId::random(),
1291 chunks.clone(),
1292 Some(&COMPRESS_CONFIG),
1293 )
1294 .await?
1295 .unwrap();
1296
1297 let root = manifest.root();
1298 assert!(root.location_dictionary().is_none());
1299 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1300 for am in root.arrays().iter() {
1301 for r in am.refs().iter() {
1302 assert!(r.compressed_location().is_none());
1303 assert!(r.location().is_some());
1304 }
1305 }
1306
1307 for chunk in &chunks {
1308 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1309 assert_eq!(payload, chunk.payload);
1310 }
1311 Ok(())
1312 }
1313
1314 #[tokio_test]
1315 async fn test_compression_mixed_payloads() -> Result<(), Box<dyn Error>> {
1316 let node = NodeId::random();
1318 let mut chunks: Vec<ChunkInfo> = (0..30)
1319 .map(|i| ChunkInfo {
1320 node: node.clone(),
1321 coord: ChunkIndices(vec![i]),
1322 payload: ChunkPayload::Virtual(VirtualChunkRef {
1323 location: VirtualChunkLocation::from_url(&format!(
1324 "s3://my-bucket/path/to/data/chunk_{i:06}"
1325 ))
1326 .unwrap(),
1327 offset: i as u64 * 1024,
1328 length: 1024,
1329 checksum: None,
1330 }),
1331 })
1332 .collect();
1333 chunks.push(ChunkInfo {
1334 node: node.clone(),
1335 coord: ChunkIndices(vec![100]),
1336 payload: ChunkPayload::Inline(Bytes::from_static(b"inline data")),
1337 });
1338 chunks.push(ChunkInfo {
1339 node: node.clone(),
1340 coord: ChunkIndices(vec![101]),
1341 payload: ChunkPayload::Ref(ChunkRef {
1342 id: ChunkId::random(),
1343 offset: 0,
1344 length: 512,
1345 }),
1346 });
1347
1348 let manifest = Manifest::from_iter(
1349 &ManifestId::random(),
1350 chunks.clone(),
1351 Some(&COMPRESS_CONFIG),
1352 )
1353 .await?
1354 .unwrap();
1355
1356 for chunk in &chunks {
1357 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1358 assert_eq!(payload, chunk.payload);
1359 }
1360 Ok(())
1361 }
1362
1363 #[tokio_test]
1364 async fn test_compression_buffer_round_trip() -> Result<(), Box<dyn Error>> {
1365 let chunks = make_virtual_chunks(50);
1367 let manifest = Manifest::from_iter(
1368 &ManifestId::random(),
1369 chunks.clone(),
1370 Some(&COMPRESS_CONFIG),
1371 )
1372 .await?
1373 .unwrap();
1374 let bytes = manifest.bytes().to_vec();
1375 let manifest2 = Manifest::from_buffer(bytes)?;
1376
1377 for chunk in &chunks {
1378 let payload = manifest2.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1379 assert_eq!(payload, chunk.payload);
1380 }
1381 Ok(())
1382 }
1383
1384 #[tokio_test]
1385 async fn test_compression_iterator_access() -> Result<(), Box<dyn Error>> {
1386 let chunks = make_virtual_chunks(50);
1388 let node = chunks[0].node.clone();
1389 let manifest = Arc::new(
1390 Manifest::from_iter(
1391 &ManifestId::random(),
1392 chunks.clone(),
1393 Some(&COMPRESS_CONFIG),
1394 )
1395 .await?
1396 .unwrap(),
1397 );
1398
1399 let iter_results: Vec<_> = manifest.iter(node)?.collect::<Vec<_>>();
1400 assert_eq!(iter_results.len(), 50);
1401 for (i, result) in iter_results.into_iter().enumerate() {
1402 let (coord, payload) = result?;
1403 assert_eq!(coord, chunks[i].coord);
1404 assert_eq!(payload, chunks[i].payload);
1405 }
1406 Ok(())
1407 }
1408
1409 #[tokio_test]
1410 async fn test_ic1_compat_no_compression() -> Result<(), Box<dyn Error>> {
1411 let chunks = make_virtual_chunks(50);
1413 let manifest = Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1414 .await?
1415 .unwrap();
1416
1417 let root = manifest.root();
1418 assert!(root.location_dictionary().is_none());
1419 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1420
1421 for am in root.arrays().iter() {
1423 for r in am.refs().iter() {
1424 if r.chunk_id().is_none() && r.inline().is_none() {
1425 assert!(r.location().is_some(), "location should be set");
1426 assert!(
1427 r.compressed_location().is_none(),
1428 "compressed_location should be None"
1429 );
1430 }
1431 }
1432 }
1433
1434 for chunk in &chunks {
1436 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1437 assert_eq!(payload, chunk.payload);
1438 }
1439 Ok(())
1440 }
1441
1442 #[tokio_test]
1443 async fn test_ic2_compression_fields() -> Result<(), Box<dyn Error>> {
1444 let chunks = make_virtual_chunks(50);
1446 let manifest = Manifest::from_iter(
1447 &ManifestId::random(),
1448 chunks.clone(),
1449 Some(&COMPRESS_CONFIG),
1450 )
1451 .await?
1452 .unwrap();
1453
1454 let root = manifest.root();
1455 assert!(root.location_dictionary().is_some());
1456 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1457
1458 for am in root.arrays().iter() {
1460 for r in am.refs().iter() {
1461 if r.chunk_id().is_none() && r.inline().is_none() {
1462 assert!(
1463 r.compressed_location().is_some(),
1464 "compressed_location should be set"
1465 );
1466 assert!(
1467 r.location().is_none(),
1468 "location should be None for compressed chunks"
1469 );
1470 }
1471 }
1472 }
1473
1474 for chunk in &chunks {
1476 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1477 assert_eq!(payload, chunk.payload);
1478 }
1479 Ok(())
1480 }
1481
1482 #[tokio_test]
1483 async fn test_compression_skipped_when_locations_too_short()
1484 -> Result<(), Box<dyn Error>> {
1485 let config = LocationCompressionConfig {
1489 min_num_chunks: 2,
1490 dictionary_max_training_samples: 500,
1491 dictionary_max_size_bytes: 256,
1492 compression_level: 3,
1493 };
1494
1495 let node = NodeId::random();
1496 let short_chunks: Vec<ChunkInfo> = (0..2)
1498 .map(|i| ChunkInfo {
1499 node: node.clone(),
1500 coord: ChunkIndices(vec![i as u32]),
1501 payload: ChunkPayload::Virtual(VirtualChunkRef {
1502 location: VirtualChunkLocation::from_url("s3://a/").unwrap(),
1503 offset: i as u64 * 1024,
1504 length: 1024,
1505 checksum: None,
1506 }),
1507 })
1508 .collect();
1509
1510 let manifest = Manifest::from_iter(
1511 &ManifestId::random(),
1512 short_chunks.clone(),
1513 Some(&config),
1514 )
1515 .await?
1516 .unwrap();
1517
1518 let root = manifest.root();
1519 assert!(root.location_dictionary().is_none());
1520 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_NONE);
1521 for am in root.arrays().iter() {
1522 for r in am.refs().iter() {
1523 assert!(r.compressed_location().is_none());
1524 assert!(r.location().is_some());
1525 }
1526 }
1527
1528 for chunk in &short_chunks {
1529 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1530 assert_eq!(payload, chunk.payload);
1531 }
1532
1533 let long_chunks: Vec<ChunkInfo> = (0..2)
1535 .map(|i| ChunkInfo {
1536 node: node.clone(),
1537 coord: ChunkIndices(vec![i as u32]),
1538 payload: ChunkPayload::Virtual(VirtualChunkRef {
1539 location: VirtualChunkLocation::from_url("s3://abc/").unwrap(),
1540 offset: i as u64 * 1024,
1541 length: 1024,
1542 checksum: None,
1543 }),
1544 })
1545 .collect();
1546
1547 let manifest = Manifest::from_iter(
1548 &ManifestId::random(),
1549 long_chunks.clone(),
1550 Some(&config),
1551 )
1552 .await?
1553 .unwrap();
1554
1555 let root = manifest.root();
1556 assert!(root.location_dictionary().is_some());
1557 assert_eq!(root.compression_algorithm(), COMPRESSION_ALG_ZSTD_DICT);
1558 for am in root.arrays().iter() {
1559 for r in am.refs().iter() {
1560 assert!(r.compressed_location().is_some());
1561 assert!(r.location().is_none());
1562 }
1563 }
1564
1565 for chunk in &long_chunks {
1566 let payload = manifest.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1567 assert_eq!(payload, chunk.payload);
1568 }
1569 Ok(())
1570 }
1571
1572 #[tokio_test]
1573 async fn test_compression_reduces_manifest_size() -> Result<(), Box<dyn Error>> {
1574 let node = NodeId::random();
1577 let chunks: Vec<ChunkInfo> = (0..100)
1578 .map(|i| ChunkInfo {
1579 node: node.clone(),
1580 coord: ChunkIndices(vec![i as u32]),
1581 payload: ChunkPayload::Virtual(VirtualChunkRef {
1582 location: VirtualChunkLocation::from_url(&format!(
1583 "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"
1584 ))
1585 .unwrap(),
1586 offset: i as u64 * 4096,
1587 length: 4096,
1588 checksum: None,
1589 }),
1590 })
1591 .collect();
1592
1593 let manifest_uncompressed =
1595 Manifest::from_iter(&ManifestId::random(), chunks.clone(), None)
1596 .await?
1597 .unwrap();
1598 let size_uncompressed = manifest_uncompressed.bytes().len();
1599
1600 assert!(!manifest_uncompressed.uses_location_compression());
1601 assert_eq!(manifest_uncompressed.num_compressed_refs(), 0);
1602
1603 let manifest_compressed = Manifest::from_iter(
1605 &ManifestId::random(),
1606 chunks.clone(),
1607 Some(&COMPRESS_CONFIG),
1608 )
1609 .await?
1610 .unwrap();
1611 let size_compressed = manifest_compressed.bytes().len();
1612
1613 assert!(manifest_compressed.uses_location_compression());
1614 assert_eq!(manifest_compressed.num_compressed_refs(), 100);
1615 assert!(manifest_compressed.location_dictionary_size().unwrap() > 0);
1616
1617 let root = manifest_compressed.root();
1619 for am in root.arrays().iter() {
1620 for r in am.refs().iter() {
1621 let compressed = r.compressed_location().unwrap();
1622 assert!(
1623 compressed.len() < 40,
1624 "compressed_location ({} bytes) should be under 40 bytes \
1625 (raw location is 118 bytes)",
1626 compressed.len(),
1627 );
1628 }
1629 }
1630
1631 assert!(
1633 size_compressed < size_uncompressed,
1634 "compressed ({size_compressed}) should be smaller than uncompressed ({size_uncompressed})"
1635 );
1636
1637 let saving_pct =
1638 (1.0 - size_compressed as f64 / size_uncompressed as f64) * 100.0;
1639 assert!(
1640 saving_pct > 15.0,
1641 "expected at least 15% size reduction with redundant locations, \
1642 got {saving_pct:.1}% (compressed={size_compressed}, uncompressed={size_uncompressed})"
1643 );
1644
1645 for chunk in &chunks {
1647 let p1 = manifest_uncompressed
1648 .get_chunk_payload(&chunk.node, &chunk.coord)
1649 .unwrap();
1650 let p2 =
1651 manifest_compressed.get_chunk_payload(&chunk.node, &chunk.coord).unwrap();
1652 assert_eq!(p1, chunk.payload);
1653 assert_eq!(p2, chunk.payload);
1654 }
1655
1656 Ok(())
1657 }
1658
1659 #[tokio_test]
1660 async fn test_chunk_payloads_with_compression() -> Result<(), Box<dyn Error>> {
1661 let chunks = make_virtual_chunks(50);
1663 let manifest = Manifest::from_iter(
1664 &ManifestId::random(),
1665 chunks.clone(),
1666 Some(&COMPRESS_CONFIG),
1667 )
1668 .await?
1669 .unwrap();
1670
1671 let payloads: Vec<_> =
1672 manifest.chunk_payloads()?.collect::<Result<Vec<_>, _>>()?;
1673 assert_eq!(payloads.len(), 50);
1674 for (i, payload) in payloads.iter().enumerate() {
1675 assert_eq!(payload, &chunks[i].payload);
1676 }
1677 Ok(())
1678 }
1679}