Skip to main content

icechunk_format/
snapshot.rs

1//! Repository state at a point in time (arrays, groups, and manifest references).
2
3use std::{borrow::Cow, collections::BTreeMap, ops::Range, sync::Arc};
4
5use bytes::Bytes;
6use chrono::{DateTime, Utc};
7use flatbuffers::{
8    FlatBufferBuilder, ForwardsUOffset, UnionWIPOffset, Vector, VerifierOptions,
9    WIPOffset,
10};
11use itertools::{Either, Itertools as _};
12use quick_cache::sync::{Cache, GuardResult};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::{
17    AttributesId, ChunkIndices, IcechunkFormatError, IcechunkFormatErrorKind,
18    IcechunkResult, ManifestId, NodeId, Path, SnapshotId,
19    flatbuffers::generated,
20    format_constants::SpecVersionBin,
21    lookup_index_by_key,
22    manifest::{Manifest, ManifestExtents, ManifestRef},
23};
24use icechunk_types::{ICResultExt as _, error::ICResultCtxExt as _};
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct DimensionShape {
28    dim_length: u64,
29    num_chunks: u32,
30}
31
32impl DimensionShape {
33    pub fn new(array_length: u64, num_chunks: u32) -> Self {
34        Self { dim_length: array_length, num_chunks }
35    }
36    pub fn array_length(&self) -> u64 {
37        self.dim_length
38    }
39    pub fn num_chunks(&self) -> u32 {
40        self.num_chunks
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ArrayShape(Vec<DimensionShape>);
46
47impl ArrayShape {
48    pub fn len(&self) -> usize {
49        self.0.len()
50    }
51    pub fn is_empty(&self) -> bool {
52        self.0.is_empty()
53    }
54
55    pub fn get(&self, ax: usize) -> Option<DimensionShape> {
56        if ax > self.len() - 1 { None } else { Some(self.0[ax].clone()) }
57    }
58
59    pub fn iter(&self) -> impl Iterator<Item = &DimensionShape> {
60        self.0.iter()
61    }
62
63    pub fn num_chunks(&self) -> impl Iterator<Item = u32> {
64        self.0.iter().map(|x| x.num_chunks())
65    }
66
67    pub fn new<I>(it: I) -> Option<Self>
68    where
69        I: IntoIterator<Item = (u64, u32)>,
70    {
71        let v = it.into_iter().map(|(al, nc)| Some(DimensionShape::new(al, nc)));
72        v.collect::<Option<Vec<_>>>().map(Self)
73    }
74
75    /// Validates the provided chunk coordinates for the array.
76    ///
77    /// This function checks if the provided chunk indices are valid for the array.
78    ///
79    /// # Arguments
80    ///
81    /// * `coord` - The chunk indices to validate.
82    ///
83    /// # Returns
84    ///
85    /// An bool indicating whether the chunk coordinates are valid.
86    ///
87    /// # Errors
88    ///
89    /// Returns false if the chunk coordinates are invalid.
90    pub fn valid_chunk_coord(&self, coord: &ChunkIndices) -> bool {
91        coord
92            .0
93            .iter()
94            .zip(self.num_chunks())
95            .all(|(index, index_permitted)| *index <= (index_permitted.max(1) - 1))
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub enum DimensionName {
101    NotSpecified,
102    Name(String),
103}
104
105impl From<Option<&str>> for DimensionName {
106    fn from(value: Option<&str>) -> Self {
107        match value {
108            Some(s) => s.into(),
109            None => DimensionName::NotSpecified,
110        }
111    }
112}
113
114impl From<DimensionName> for Option<String> {
115    fn from(value: DimensionName) -> Option<String> {
116        match value {
117            DimensionName::NotSpecified => None,
118            DimensionName::Name(name) => Some(name),
119        }
120    }
121}
122
123impl From<&str> for DimensionName {
124    fn from(value: &str) -> Self {
125        if value.is_empty() {
126            DimensionName::NotSpecified
127        } else {
128            DimensionName::Name(value.to_string())
129        }
130    }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub enum NodeData {
135    Array {
136        shape: ArrayShape,
137        dimension_names: Option<Vec<DimensionName>>,
138        manifests: Vec<ManifestRef>,
139    },
140    Group,
141}
142
143#[derive(Clone, Debug, Hash, PartialEq, Eq)]
144pub enum NodeType {
145    Group,
146    Array,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct NodeSnapshot {
151    pub id: NodeId,
152    pub path: Path,
153    pub user_data: Bytes,
154    pub node_data: NodeData,
155}
156
157impl NodeSnapshot {
158    pub fn node_type(&self) -> NodeType {
159        match &self.node_data {
160            NodeData::Group => NodeType::Group,
161            NodeData::Array { .. } => NodeType::Array,
162        }
163    }
164}
165
166impl From<&generated::ObjectId8> for NodeId {
167    fn from(value: &generated::ObjectId8) -> Self {
168        NodeId::new(value.0)
169    }
170}
171
172impl From<&generated::ObjectId12> for ManifestId {
173    fn from(value: &generated::ObjectId12) -> Self {
174        ManifestId::new(value.0)
175    }
176}
177
178impl From<&generated::ObjectId12> for AttributesId {
179    fn from(value: &generated::ObjectId12) -> Self {
180        AttributesId::new(value.0)
181    }
182}
183
184impl<'a> From<generated::ManifestRef<'a>> for ManifestRef {
185    fn from(value: generated::ManifestRef<'a>) -> Self {
186        let extents = ManifestExtents::from_ranges_iter(
187            value
188                .extents()
189                .iter()
190                .map(|range| Range { start: range.from(), end: range.to() }),
191        );
192        ManifestRef { object_id: value.object_id().into(), extents }
193    }
194}
195
196impl TryFrom<&generated::DimensionShape> for DimensionShape {
197    type Error = IcechunkFormatError;
198
199    fn try_from(value: &generated::DimensionShape) -> Result<Self, Self::Error> {
200        if value.chunk_length() == 0 && value.array_length() != 0 {
201            return Err(IcechunkFormatErrorKind::InvalidArrayMetadata(format!(
202                "Array metadata has chunk_length = 0 while array_length={:?}",
203                value.array_length()
204            )))
205            .capture();
206        }
207        let num_chunks = if value.chunk_length() == 0 {
208            0
209        } else {
210            value.array_length().div_ceil(value.chunk_length()) as u32
211        };
212        Ok(DimensionShape { dim_length: value.array_length(), num_chunks })
213    }
214}
215
216impl<'a> From<&generated::DimensionShapeV2<'a>> for DimensionShape {
217    fn from(value: &generated::DimensionShapeV2<'a>) -> Self {
218        DimensionShape {
219            dim_length: value.array_length(),
220            num_chunks: value.num_chunks(),
221        }
222    }
223}
224
225impl<'a> TryFrom<generated::ArrayNodeData<'a>> for NodeData {
226    type Error = IcechunkFormatError;
227
228    fn try_from(value: generated::ArrayNodeData<'a>) -> Result<Self, Self::Error> {
229        let dimension_names = value
230            .dimension_names()
231            .map(|dn| dn.iter().map(|name| name.name().into()).collect());
232        // In our flatbuffers the V1 `shape` is required and will be an empty `[]` for V2 repos
233        // Note that `shape` is *also* `[]` for scalars in V1 repos.
234        // So we branch on `shape_v2` which is optional, and thus None, on V1 repos.
235        let shape = ArrayShape(match value.shape_v2() {
236            None => value
237                .shape()
238                .iter()
239                .map(|dim| dim.try_into())
240                .collect::<Result<Vec<_>, IcechunkFormatError>>()?,
241            Some(x) => x.iter().map(|dim| (&dim).into()).collect(),
242        });
243        let manifests = value.manifests().iter().map(|m| m.into()).collect();
244        Ok(Self::Array { shape, dimension_names, manifests })
245    }
246}
247
248impl<'a> From<generated::GroupNodeData<'a>> for NodeData {
249    fn from(_: generated::GroupNodeData<'a>) -> Self {
250        Self::Group
251    }
252}
253
254impl<'a> TryFrom<generated::NodeSnapshot<'a>> for NodeSnapshot {
255    type Error = IcechunkFormatError;
256
257    fn try_from(value: generated::NodeSnapshot<'a>) -> Result<Self, Self::Error> {
258        #[expect(clippy::expect_used, clippy::panic)]
259        let node_data: NodeData = match value.node_data_type() {
260            generated::NodeData::Array => value
261                .node_data_as_array()
262                .expect("Bug in flatbuffers library")
263                .try_into()?,
264            generated::NodeData::Group => {
265                value.node_data_as_group().expect("Bug in flatbuffers library").into()
266            }
267            x => panic!("Invalid node data type in flatbuffers file {x:?}"),
268        };
269        let res = NodeSnapshot {
270            id: value.id().into(),
271            path: Path::from_trusted(value.path()),
272            node_data,
273            user_data: Bytes::copy_from_slice(value.user_data().bytes()),
274        };
275        Ok(res)
276    }
277}
278
279impl From<&generated::ManifestFileInfo> for ManifestFileInfo {
280    fn from(value: &generated::ManifestFileInfo) -> Self {
281        Self {
282            id: value.id().into(),
283            size_bytes: value.size_bytes(),
284            num_chunk_refs: value.num_chunk_refs(),
285        }
286    }
287}
288
289impl TryFrom<&generated::ManifestFileInfoV2<'_>> for ManifestFileInfo {
290    type Error = IcechunkFormatError;
291
292    fn try_from(value: &generated::ManifestFileInfoV2<'_>) -> Result<Self, Self::Error> {
293        let id = value.id().map(|id| id.into()).ok_or_else(|| {
294            IcechunkFormatError::capture(IcechunkFormatErrorKind::InvalidFlatBuffer(
295                flatbuffers::InvalidFlatbuffer::MissingRequiredField {
296                    required: Cow::Borrowed("id"),
297                    error_trace: Default::default(),
298                },
299            ))
300        })?;
301        Ok(Self {
302            id,
303            size_bytes: value.size_bytes(),
304            num_chunk_refs: value.num_chunk_refs(),
305        })
306    }
307}
308
309pub type SnapshotProperties = BTreeMap<String, Value>;
310
311/// Insert a key-value pair into the `__icechunk` namespace object within snapshot properties.
312/// Creates the `__icechunk` object if it doesn't exist.
313pub fn inject_icechunk_metadata(
314    properties: &mut SnapshotProperties,
315    key: &str,
316    value: Value,
317) {
318    match properties.get_mut("__icechunk") {
319        Some(Value::Object(map)) => {
320            map.insert(key.to_string(), value);
321        }
322        _ => {
323            properties
324                .insert("__icechunk".to_string(), serde_json::json!({ key: value }));
325        }
326    }
327}
328
329#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Eq, Hash)]
330pub struct ManifestFileInfo {
331    pub id: ManifestId,
332    pub size_bytes: u64,
333    pub num_chunk_refs: u32,
334}
335
336impl ManifestFileInfo {
337    pub fn new(manifest: &Manifest, size_bytes: u64) -> Self {
338        Self {
339            id: manifest.id().clone(),
340            num_chunk_refs: manifest.len() as u32,
341            size_bytes,
342        }
343    }
344}
345
346// It is _extremely_ common to set/get chunks in large batches sequentially.
347// Without a cache we incur the cost of repeatedly decoding the flatbuffer.
348// This cache is very small (size 2 currently), because we could cache a large number of
349// snapshots. The size of 2 is fine for typically workloads that iterate sequentially
350// through nodes.
351// We cannot choose 1 because of a bug in the `quick_cache` dependency
352// where a size-1 cache is effectively no cache
353// https://github.com/arthurprs/quick-cache/issues/105
354const SNAPSHOT_NODE_CACHE_SIZE: usize = 2;
355
356pub struct Snapshot {
357    buffer: Vec<u8>,
358    spec_version: SpecVersionBin,
359    node_cache: Cache<Path, Arc<NodeSnapshot>>,
360}
361
362impl std::fmt::Debug for Snapshot {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        let nodes =
365            self.iter().map(|n| n.map(|n| n.path.to_string())).collect::<Vec<_>>();
366        #[expect(deprecated)]
367        f.debug_struct("Snapshot")
368            .field("id", &self.id())
369            .field("parent_id", &self.parent_id())
370            .field("flushed_at", &self.flushed_at())
371            .field("nodes", &nodes)
372            .field(
373                "manifests",
374                &self.manifest_files().collect::<IcechunkResult<Vec<_>>>(),
375            )
376            .field("message", &self.message())
377            .field("metadata", &self.metadata())
378            .finish_non_exhaustive()
379    }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Hash)]
383pub struct SnapshotInfo {
384    pub id: SnapshotId,
385    pub parent_id: Option<SnapshotId>,
386    pub flushed_at: DateTime<Utc>,
387    pub message: String,
388    pub metadata: SnapshotProperties,
389}
390
391impl TryFrom<&Snapshot> for SnapshotInfo {
392    type Error = IcechunkFormatError;
393
394    fn try_from(value: &Snapshot) -> Result<Self, Self::Error> {
395        #[expect(deprecated)]
396        Ok(Self {
397            id: value.id().clone(),
398            parent_id: value.parent_id().clone(),
399            flushed_at: value.flushed_at()?,
400            message: value.message().clone(),
401            metadata: value.metadata()?.clone(),
402        })
403    }
404}
405
406impl SnapshotInfo {
407    pub fn is_initial(&self) -> bool {
408        self.id == Snapshot::INITIAL_SNAPSHOT_ID
409    }
410}
411
412impl SnapshotId {
413    pub fn is_initial(&self) -> bool {
414        *self == Snapshot::INITIAL_SNAPSHOT_ID
415    }
416}
417
418static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
419    max_depth: 64,
420    max_tables: 50_000_000,
421    max_apparent_size: 1 << 31, // taken from the default
422    ignore_missing_null_terminator: true,
423};
424
425impl Snapshot {
426    pub const INITIAL_COMMIT_MESSAGE: &'static str = "Repository initialized";
427    pub const INITIAL_SNAPSHOT_ID: SnapshotId = SnapshotId::new([
428        0x0b, 0x1c, 0xc8, 0xd6, 0x78, 0x75, 0x80, 0xf0, 0xe3, 0x3a, 0x65,
429        0x34, // Decodes as 1CECHNKREP0F1RSTCMT0
430    ]);
431
432    pub fn from_buffer(
433        spec_version: SpecVersionBin,
434        buffer: Vec<u8>,
435    ) -> IcechunkResult<Snapshot> {
436        let _ = flatbuffers::root_with_opts::<generated::Snapshot<'_>>(
437            &ROOT_OPTIONS,
438            buffer.as_slice(),
439        )
440        .capture()?;
441        Ok(Snapshot {
442            buffer,
443            spec_version,
444            // this number is low because we cache a very large number of snapshot nodes.
445            node_cache: Cache::new(SNAPSHOT_NODE_CACHE_SIZE),
446        })
447    }
448
449    pub fn bytes(&self) -> &[u8] {
450        self.buffer.as_slice()
451    }
452
453    #[expect(clippy::too_many_arguments)]
454    pub fn from_iter<I>(
455        id: Option<SnapshotId>,
456        parent_id: Option<SnapshotId>,
457        spec_version: SpecVersionBin,
458        message: &str,
459        properties: Option<SnapshotProperties>,
460        mut manifest_files: Vec<ManifestFileInfo>,
461        flushed_at: Option<DateTime<Utc>>,
462        sorted_iter: I,
463    ) -> IcechunkResult<Self>
464    where
465        I: IntoIterator<Item = IcechunkResult<NodeSnapshot>>,
466    {
467        // TODO: what's a good capacity?
468        let mut builder = FlatBufferBuilder::with_capacity(4_096);
469
470        manifest_files.sort_by(|a, b| a.id.cmp(&b.id));
471        let (manifest_files_v1, manifest_files_v2) = match spec_version {
472            SpecVersionBin::V1 => {
473                let ms1 = manifest_files
474                    .iter()
475                    .map(|mfi| {
476                        let id = generated::ObjectId12::new(&mfi.id.0);
477                        generated::ManifestFileInfo::new(
478                            &id,
479                            mfi.size_bytes,
480                            mfi.num_chunk_refs,
481                        )
482                    })
483                    .collect::<Vec<_>>();
484                let ms1 = builder.create_vector(&ms1);
485                let ms2 = None;
486                (ms1, ms2)
487            }
488            SpecVersionBin::V2 => {
489                let ms2 = manifest_files
490                    .iter()
491                    .map(|mfi| {
492                        let id = generated::ObjectId12::new(&mfi.id.0);
493
494                        let args = generated::ManifestFileInfoV2Args {
495                            id: Some(&id),
496                            size_bytes: mfi.size_bytes,
497                            num_chunk_refs: mfi.num_chunk_refs,
498                            extra: None,
499                        };
500                        generated::ManifestFileInfoV2::create(&mut builder, &args)
501                    })
502                    .collect::<Vec<_>>();
503                let ms2 = builder.create_vector(&ms2);
504                let ms1 = builder.create_vector::<generated::ManifestFileInfo>(&[]);
505                (ms1, Some(ms2))
506            }
507        };
508
509        let metadata_items: Vec<_> = properties
510            .unwrap_or_default()
511            .iter()
512            .map(|(k, v)| {
513                let name = builder.create_shared_string(k.as_str());
514                let serialized = if spec_version == SpecVersionBin::V1 {
515                    rmp_serde::to_vec(v).map_err(Box::new).capture()?
516                } else {
517                    flexbuffers::to_vec(v).map_err(Box::new).capture()?
518                };
519
520                let value = builder.create_vector(serialized.as_slice());
521                Ok::<_, IcechunkFormatError>(generated::MetadataItem::create(
522                    &mut builder,
523                    &generated::MetadataItemArgs { name: Some(name), value: Some(value) },
524                ))
525            })
526            .try_collect()?;
527        let metadata_items = builder.create_vector(metadata_items.as_slice());
528
529        let message = builder.create_string(message);
530        // Icechunk 2.0 no longer uses this field
531        let parent_id = parent_id.map(|oid| generated::ObjectId12::new(&oid.0));
532        let flushed_at = flushed_at.unwrap_or_else(Utc::now).timestamp_micros() as u64;
533        let id = generated::ObjectId12::new(&id.unwrap_or_else(SnapshotId::random).0);
534
535        let nodes: Vec<_> = sorted_iter
536            .into_iter()
537            .map(|node| node.and_then(|node| mk_node(&mut builder, &node, spec_version)))
538            .try_collect()?;
539        let nodes = builder.create_vector(&nodes);
540
541        let snap = generated::Snapshot::create(
542            &mut builder,
543            &generated::SnapshotArgs {
544                id: Some(&id),
545                parent_id: parent_id.as_ref(),
546                nodes: Some(nodes),
547                flushed_at,
548                message: Some(message),
549                metadata: Some(metadata_items),
550                manifest_files: Some(manifest_files_v1),
551                manifest_files_v2,
552                ..Default::default()
553            },
554        );
555
556        builder.finish(snap, Some("Ichk"));
557        let (mut buffer, offset) = builder.collapse();
558        buffer.drain(0..offset);
559        buffer.shrink_to_fit();
560        Ok(Snapshot {
561            buffer,
562            spec_version,
563            // this number is low because we cache a very large number of snapshot nodes.
564            node_cache: Cache::new(SNAPSHOT_NODE_CACHE_SIZE),
565        })
566    }
567
568    pub fn initial(spec_version: SpecVersionBin) -> IcechunkResult<Self> {
569        let mut properties = SnapshotProperties::default();
570        inject_icechunk_metadata(&mut properties, "is_root", Value::from(true));
571        let nodes = Vec::<IcechunkResult<NodeSnapshot>>::new();
572        Self::from_iter(
573            Some(Self::INITIAL_SNAPSHOT_ID),
574            None,
575            spec_version,
576            Self::INITIAL_COMMIT_MESSAGE,
577            Some(properties),
578            Default::default(),
579            None,
580            nodes,
581        )
582    }
583
584    #[expect(unsafe_code)]
585    fn root(&self) -> generated::Snapshot<'_> {
586        // SAFETY: self.buffer was serialized by our own flatbuffers serialization code.
587        // We skip validation for performance; a corrupt buffer here indicates
588        // file corruption or a bad Icechunk implementation, not a caller error.
589        unsafe { flatbuffers::root_unchecked::<generated::Snapshot<'_>>(&self.buffer) }
590    }
591
592    pub fn id(&self) -> SnapshotId {
593        SnapshotId::new(self.root().id().0)
594    }
595
596    #[deprecated(
597        since = "2.0.0",
598        note = "New versions of icechunk don't use this field and initialize it to None"
599    )]
600    pub fn parent_id(&self) -> Option<SnapshotId> {
601        self.root().parent_id().map(|pid| SnapshotId::new(pid.0))
602    }
603
604    pub fn metadata(&self) -> IcechunkResult<SnapshotProperties> {
605        self.root()
606            .metadata()
607            .iter()
608            .map(|item| {
609                let key = item.name().to_string();
610                let value = if self.spec_version == SpecVersionBin::V1 {
611                    rmp_serde::from_slice(item.value().bytes())
612                        .map_err(Box::new)
613                        .capture()?
614                } else {
615                    flexbuffers::from_slice(item.value().bytes())
616                        .map_err(Box::new)
617                        .capture()?
618                };
619                Ok((key, value))
620            })
621            .try_collect()
622    }
623
624    pub fn flushed_at(&self) -> IcechunkResult<DateTime<Utc>> {
625        let ts = self.root().flushed_at();
626        let ts: i64 = ts
627            .try_into()
628            .map_err(|_| IcechunkFormatErrorKind::InvalidTimestamp)
629            .capture()?;
630        DateTime::from_timestamp_micros(ts)
631            .ok_or(IcechunkFormatErrorKind::InvalidTimestamp)
632            .capture()
633    }
634
635    pub fn message(&self) -> String {
636        self.root().message().to_string()
637    }
638
639    pub fn manifest_files(
640        &self,
641    ) -> impl Iterator<Item = IcechunkResult<ManifestFileInfo>> + '_ {
642        let root = self.root();
643        if let Some(mf2) = root.manifest_files_v2() {
644            Either::Left(mf2.iter().map(|mf| (&mf).try_into()))
645        } else {
646            Either::Right(root.manifest_files().iter().map(|mf| Ok(mf.into())))
647        }
648    }
649
650    /// Cretase a new `Snapshot` with all the same data as `new_child` but `self` as parent
651    #[deprecated(
652        since = "2.0.0",
653        note = "Shouldn't be necessary after 2.0, only to support Icechunk 1 repos"
654    )]
655    pub fn adopt(&self, new_child: &Snapshot) -> IcechunkResult<Self> {
656        // Rust flatbuffers implementation doesn't allow mutation of scalars, so we need to
657        // create a whole new buffer and write to it in full
658
659        Snapshot::from_iter(
660            Some(new_child.id()),
661            Some(self.id()),
662            SpecVersionBin::V1, // 2.0 doesn't use adopt
663            &new_child.message(),
664            Some(new_child.metadata()?.clone()),
665            new_child.manifest_files().try_collect()?,
666            Some(new_child.flushed_at()?),
667            new_child.iter(),
668        )
669    }
670
671    fn _get_node(&self, path: &Path) -> IcechunkResult<NodeSnapshot> {
672        let res = self
673            .root()
674            .nodes()
675            .lookup_by_key(path.to_string().as_str(), |node, path| node.path().cmp(path))
676            .ok_or_else(|| IcechunkFormatErrorKind::NodeNotFound { path: path.clone() })
677            .capture()?;
678        res.try_into()
679    }
680
681    pub fn get_node(&self, path: &Path) -> IcechunkResult<Arc<NodeSnapshot>> {
682        use GuardResult::*;
683        match self.node_cache.get_value_or_guard(path, None) {
684            Value(node) => Ok(node),
685            Timeout => Ok(Arc::new(self._get_node(path)?)),
686            Guard(guard) => {
687                let node = self._get_node(path)?;
688                let node = Arc::new(node);
689                let _ = guard.insert(Arc::clone(&node));
690                Ok(node)
691            }
692        }
693    }
694
695    pub fn get_node_index(&self, path: &Path) -> IcechunkResult<usize> {
696        let path_str = path.to_string();
697        let res =
698            lookup_index_by_key(self.root().nodes(), path_str.as_str(), |node, path| {
699                node.path().cmp(path)
700            })
701            .ok_or_else(|| IcechunkFormatErrorKind::NodeNotFound { path: path.clone() })
702            .capture()?;
703        Ok(res)
704    }
705
706    pub fn iter(&self) -> impl Iterator<Item = IcechunkResult<NodeSnapshot>> + '_ {
707        self.root().nodes().iter().map(|node| node.try_into().inject())
708    }
709
710    pub fn iter_arc(
711        self: Arc<Self>,
712        parent_group: &Path,
713    ) -> impl Iterator<Item = IcechunkResult<NodeSnapshot>> + use<> {
714        NodeIterator::new(self, parent_group)
715    }
716
717    pub fn len(&self) -> usize {
718        self.root().nodes().len()
719    }
720
721    #[must_use]
722    pub fn is_empty(&self) -> bool {
723        self.len() == 0
724    }
725
726    pub fn manifest_info(
727        &self,
728        id: &ManifestId,
729    ) -> IcechunkResult<Option<ManifestFileInfo>> {
730        let root = self.root();
731        if let Some(mf2) = root.manifest_files_v2() {
732            mf2.iter()
733                .find(|mf| mf.id().is_some_and(|mid| mid.0 == id.0))
734                .map(|mf| (&mf).try_into())
735                .transpose()
736        } else {
737            Ok(root
738                .manifest_files()
739                .iter()
740                .find(|mi| mi.id().0 == id.0)
741                .map(|man| man.into()))
742        }
743    }
744}
745
746struct NodeIterator {
747    snapshot: Arc<Snapshot>,
748    next_index: usize,
749    prefix: String,
750}
751
752impl NodeIterator {
753    fn new(snapshot: Arc<Snapshot>, parent_group: &Path) -> Self {
754        let next_index = snapshot.get_node_index(parent_group).unwrap_or_default();
755        let prefix = parent_group.to_string();
756        let prefix = if prefix == "/" { String::new() } else { prefix };
757        NodeIterator { snapshot, next_index, prefix }
758    }
759}
760
761impl Iterator for NodeIterator {
762    type Item = IcechunkResult<NodeSnapshot>;
763
764    fn next(&mut self) -> Option<Self::Item> {
765        let nodes = self.snapshot.root().nodes();
766        loop {
767            // we need to loop over all nodes starting from the index
768            // until we are sure we'll see no more children
769            if self.next_index >= nodes.len() {
770                return None;
771            }
772
773            let node: IcechunkResult<NodeSnapshot> =
774                nodes.get(self.next_index).try_into();
775
776            match node {
777                Ok(res) => {
778                    let node_path = res.path.to_string();
779                    if let Some(after_prefix) =
780                        node_path.strip_prefix(self.prefix.as_str())
781                        && (after_prefix.is_empty() || after_prefix.starts_with('/'))
782                    {
783                        self.next_index += 1;
784                        return Some(Ok(res));
785                    } else if node_path.as_str() > self.prefix.as_str()
786                        && !node_path.starts_with(self.prefix.as_str())
787                    {
788                        // We've passed all possible children of the prefix
789                        return None;
790                    } else {
791                        // Not a match but there may be matches later (foo-bar" comes before "foo/bar")
792                        self.next_index += 1;
793                    }
794                }
795                Err(err) => return Some(Err(err)),
796            }
797        }
798    }
799}
800
801fn mk_node<'bldr>(
802    builder: &mut FlatBufferBuilder<'bldr>,
803    node: &NodeSnapshot,
804    spec_version: SpecVersionBin,
805) -> IcechunkResult<WIPOffset<generated::NodeSnapshot<'bldr>>> {
806    let id = generated::ObjectId8::new(&node.id.0);
807    let path = builder.create_string(node.path.to_string().as_str());
808    let (node_data_type, node_data) =
809        mk_node_data(builder, &node.node_data, spec_version)?;
810    let user_data = Some(builder.create_vector(&node.user_data));
811    Ok(generated::NodeSnapshot::create(
812        builder,
813        &generated::NodeSnapshotArgs {
814            id: Some(&id),
815            path: Some(path),
816            node_data_type,
817            node_data,
818            user_data,
819            ..Default::default()
820        },
821    ))
822}
823
824type ShapeV1<'a> = WIPOffset<Vector<'a, generated::DimensionShape>>;
825type ShapeV2<'a> =
826    WIPOffset<Vector<'a, ForwardsUOffset<generated::DimensionShapeV2<'a>>>>;
827
828fn mk_array_shapes<'a>(
829    builder: &mut FlatBufferBuilder<'a>,
830    spec_version: SpecVersionBin,
831    shape: &ArrayShape,
832) -> (Option<ShapeV1<'a>>, Option<ShapeV2<'a>>) {
833    use SpecVersionBin::*;
834    match spec_version {
835        V1 => {
836            let shape = shape
837                .0
838                .iter()
839                .map(|ds| {
840                    let chunk_length = if ds.num_chunks == 0 {
841                        debug_assert_eq!(ds.dim_length, 0);
842                        0
843                    } else {
844                        // FIXME: this can be *very* wrong, but I'm not sure it really matters
845                        //        we still preserve num_chunks, and return the proper Zarr JSON metadata
846                        //        which is stored in NodeData::user_data which stores the zarr.json exactly
847                        //        as provided by Zarr
848                        ds.dim_length.div_ceil(ds.num_chunks as u64)
849                    };
850
851                    generated::DimensionShape::new(ds.dim_length, chunk_length)
852                })
853                .collect::<Vec<_>>();
854            (Some(builder.create_vector(shape.as_slice())), None)
855        }
856        V2 => {
857            let shape = shape
858                .0
859                .iter()
860                .map(|ds| {
861                    generated::DimensionShapeV2::create(
862                        builder,
863                        &generated::DimensionShapeV2Args {
864                            array_length: ds.dim_length,
865                            num_chunks: ds.num_chunks,
866                        },
867                    )
868                })
869                .collect::<Vec<_>>();
870            let empty_shape_for_v1_compat =
871                builder.create_vector(&[] as &[generated::DimensionShape]);
872            (
873                Some(empty_shape_for_v1_compat),
874                Some(builder.create_vector(shape.as_slice())),
875            )
876        }
877    }
878}
879
880fn mk_node_data(
881    builder: &mut FlatBufferBuilder<'_>,
882    node_data: &NodeData,
883    spec_version: SpecVersionBin,
884) -> IcechunkResult<(generated::NodeData, Option<WIPOffset<UnionWIPOffset>>)> {
885    match node_data {
886        NodeData::Array { manifests, dimension_names, shape } => {
887            let manifests = manifests
888                .iter()
889                .map(|manref| {
890                    let object_id = generated::ObjectId12::new(&manref.object_id.0);
891                    let extents = manref
892                        .extents
893                        .iter()
894                        .map(|range| {
895                            generated::ChunkIndexRange::new(range.start, range.end)
896                        })
897                        .collect::<Vec<_>>();
898                    let extents = builder.create_vector(&extents);
899                    generated::ManifestRef::create(
900                        builder,
901                        &generated::ManifestRefArgs {
902                            object_id: Some(&object_id),
903                            extents: Some(extents),
904                        },
905                    )
906                })
907                .collect::<Vec<_>>();
908            let manifests = builder.create_vector(manifests.as_slice());
909            let dimensions = dimension_names.as_ref().map(|dn| {
910                let names = dn
911                    .iter()
912                    .map(|n| match n {
913                        DimensionName::Name(s) => {
914                            let n = builder.create_shared_string(s.as_str());
915                            generated::DimensionName::create(
916                                builder,
917                                &generated::DimensionNameArgs { name: Some(n) },
918                            )
919                        }
920                        DimensionName::NotSpecified => generated::DimensionName::create(
921                            builder,
922                            &generated::DimensionNameArgs { name: None },
923                        ),
924                    })
925                    .collect::<Vec<_>>();
926                builder.create_vector(names.as_slice())
927            });
928            let (shape_v1, shape_v2) = mk_array_shapes(builder, spec_version, shape);
929            let node_data = generated::ArrayNodeData::create(
930                builder,
931                &generated::ArrayNodeDataArgs {
932                    manifests: Some(manifests),
933                    shape: shape_v1,
934                    shape_v2,
935                    dimension_names: dimensions,
936                },
937            );
938            Ok((generated::NodeData::Array, Some(node_data.as_union_value())))
939        }
940        NodeData::Group => Ok((
941            generated::NodeData::Group,
942            Some(
943                generated::GroupNodeData::create(
944                    builder,
945                    &generated::GroupNodeDataArgs {},
946                )
947                .as_union_value(),
948            ),
949        )),
950    }
951}
952
953#[cfg(test)]
954#[expect(unused_qualifications)] // proptest macros generate fully qualified paths
955mod tests {
956    use crate::{IcechunkFormatError, ObjectId};
957
958    use super::*;
959    use crate::{
960        roundtrip_serialization_tests,
961        strategies::{ShapeDim, manifest_file_info, node_snapshot, shapes_and_dims},
962    };
963    use pretty_assertions::assert_eq;
964    use proptest::prelude::*;
965    use std::iter::{self};
966
967    roundtrip_serialization_tests!(
968        serialize_and_deserialize_node_snapshot - node_snapshot,
969        serialize_and_deserialize_manifest_file_info - manifest_file_info
970    );
971
972    #[icechunk_macros::test]
973    fn test_get_node() -> Result<(), Box<dyn std::error::Error>> {
974        let shape1 = ArrayShape::new(vec![(10u64, 3), (20, 2), (30, 1)]).unwrap();
975        let dim_names1 = Some(vec!["x".into(), "y".into(), "t".into()]);
976
977        let shape2 = shape1.clone();
978        let dim_names2 = Some(vec![
979            DimensionName::NotSpecified,
980            DimensionName::NotSpecified,
981            "t".into(),
982        ]);
983
984        let shape3 = shape1.clone();
985        let dim_names3 = None;
986
987        let man_ref1 = ManifestRef {
988            object_id: ObjectId::random(),
989            extents: ManifestExtents::new(&[0, 0, 0], &[100, 100, 100]),
990        };
991        let man_ref2 = ManifestRef {
992            object_id: ObjectId::random(),
993            extents: ManifestExtents::new(&[0, 0, 0], &[100, 100, 100]),
994        };
995
996        let node_ids = iter::repeat_with(NodeId::random).take(7).collect::<Vec<_>>();
997        // nodes must be sorted by path
998        let nodes = vec![
999            NodeSnapshot {
1000                path: Path::root(),
1001                id: node_ids[0].clone(),
1002                user_data: Bytes::new(),
1003                node_data: NodeData::Group,
1004            },
1005            NodeSnapshot {
1006                path: "/a".try_into().unwrap(),
1007                id: node_ids[1].clone(),
1008                user_data: Bytes::new(),
1009                node_data: NodeData::Group,
1010            },
1011            NodeSnapshot {
1012                path: "/array2".try_into().unwrap(),
1013                id: node_ids[5].clone(),
1014                user_data: Bytes::new(),
1015                node_data: NodeData::Array {
1016                    shape: shape2.clone(),
1017                    dimension_names: dim_names2.clone(),
1018                    manifests: vec![],
1019                },
1020            },
1021            NodeSnapshot {
1022                path: "/b".try_into().unwrap(),
1023                id: node_ids[2].clone(),
1024                user_data: Bytes::new(),
1025                node_data: NodeData::Group,
1026            },
1027            NodeSnapshot {
1028                path: "/b/array1".try_into().unwrap(),
1029                id: node_ids[4].clone(),
1030                user_data: Bytes::copy_from_slice(b"hello"),
1031                node_data: NodeData::Array {
1032                    shape: shape1.clone(),
1033                    dimension_names: dim_names1.clone(),
1034                    manifests: vec![man_ref1.clone(), man_ref2.clone()],
1035                },
1036            },
1037            NodeSnapshot {
1038                path: "/b/array3".try_into().unwrap(),
1039                id: node_ids[6].clone(),
1040                user_data: Bytes::new(),
1041                node_data: NodeData::Array {
1042                    shape: shape3.clone(),
1043                    dimension_names: dim_names3.clone(),
1044                    manifests: vec![],
1045                },
1046            },
1047            NodeSnapshot {
1048                path: "/b/c".try_into().unwrap(),
1049                id: node_ids[3].clone(),
1050                user_data: Bytes::copy_from_slice(b"bye"),
1051                node_data: NodeData::Group,
1052            },
1053        ];
1054        let manifests = vec![
1055            ManifestFileInfo {
1056                id: man_ref1.object_id.clone(),
1057                size_bytes: 1_000_000,
1058                num_chunk_refs: 100_000,
1059            },
1060            ManifestFileInfo {
1061                id: man_ref2.object_id.clone(),
1062                size_bytes: 1_000_000,
1063                num_chunk_refs: 100_000,
1064            },
1065        ];
1066        let st = Snapshot::from_iter(
1067            None,
1068            None,
1069            SpecVersionBin::current(),
1070            "",
1071            Default::default(),
1072            manifests,
1073            None,
1074            nodes.into_iter().map(Ok::<NodeSnapshot, IcechunkFormatError>),
1075        )
1076        .unwrap();
1077
1078        assert!(matches!(
1079            st.get_node(&"/nonexistent".try_into().unwrap()),
1080            Err(IcechunkFormatError {
1081                kind: IcechunkFormatErrorKind::NodeNotFound {
1082                    path
1083                },
1084                ..
1085            }) if path == "/nonexistent".try_into().unwrap()
1086        ));
1087
1088        let node = st.get_node(&"/b/c".try_into().unwrap()).unwrap();
1089        assert_eq!(
1090            node,
1091            Arc::new(NodeSnapshot {
1092                path: "/b/c".try_into().unwrap(),
1093                id: node_ids[3].clone(),
1094                user_data: Bytes::copy_from_slice(b"bye"),
1095                node_data: NodeData::Group,
1096            }),
1097        );
1098        let node = st.get_node(&Path::root()).unwrap();
1099        assert_eq!(
1100            node,
1101            Arc::new(NodeSnapshot {
1102                path: Path::root(),
1103                id: node_ids[0].clone(),
1104                user_data: Bytes::new(),
1105                node_data: NodeData::Group,
1106            }),
1107        );
1108        let node = st.get_node(&"/b/array1".try_into().unwrap()).unwrap();
1109        assert_eq!(
1110            node,
1111            Arc::new(NodeSnapshot {
1112                path: "/b/array1".try_into().unwrap(),
1113                id: node_ids[4].clone(),
1114                user_data: Bytes::copy_from_slice(b"hello"),
1115                node_data: NodeData::Array {
1116                    shape: shape1.clone(),
1117                    dimension_names: dim_names1.clone(),
1118                    manifests: vec![man_ref1, man_ref2]
1119                },
1120            }),
1121        );
1122        let node = st.get_node(&"/array2".try_into().unwrap()).unwrap();
1123        assert_eq!(
1124            node,
1125            Arc::new(NodeSnapshot {
1126                path: "/array2".try_into().unwrap(),
1127                id: node_ids[5].clone(),
1128                user_data: Bytes::new(),
1129                node_data: NodeData::Array {
1130                    shape: shape2.clone(),
1131                    dimension_names: dim_names2.clone(),
1132                    manifests: vec![]
1133                },
1134            }),
1135        );
1136        let node = st.get_node(&"/b/array3".try_into().unwrap()).unwrap();
1137        assert_eq!(
1138            node,
1139            Arc::new(NodeSnapshot {
1140                path: "/b/array3".try_into().unwrap(),
1141                id: node_ids[6].clone(),
1142                user_data: Bytes::new(),
1143                node_data: NodeData::Array {
1144                    shape: shape3.clone(),
1145                    dimension_names: dim_names3.clone(),
1146                    manifests: vec![]
1147                },
1148            }),
1149        );
1150        Ok(())
1151    }
1152
1153    #[icechunk_macros::test]
1154    fn test_valid_chunk_coord() {
1155        let shape1 =
1156            ArrayShape::new(vec![(10_000, 10), (10_001, 11), (9_999, 10)]).unwrap();
1157        let shape2 = ArrayShape::new(vec![(0, 1_000), (0, 1_000), (0, 1_000)]).unwrap();
1158        let coord1 = ChunkIndices(vec![9, 10, 9]);
1159        let coord2 = ChunkIndices(vec![10, 11, 10]);
1160        let coord3 = ChunkIndices(vec![0, 0, 0]);
1161
1162        assert!(shape1.valid_chunk_coord(&coord1));
1163        assert!(!shape1.valid_chunk_coord(&coord2));
1164        assert!(shape2.valid_chunk_coord(&coord3));
1165    }
1166
1167    #[icechunk_macros::test]
1168    fn test_valid_chunk_coord_zero_length_dim() {
1169        let shape = ArrayShape::new(vec![(0, 0)]).unwrap();
1170        assert!(shape.valid_chunk_coord(&ChunkIndices(vec![0])));
1171        assert!(!shape.valid_chunk_coord(&ChunkIndices(vec![1])));
1172    }
1173
1174    #[test_strategy::proptest]
1175    fn test_prop_valid_chunk_coord(
1176        #[strategy(shapes_and_dims(None, Some(1)))] shape_dim: ShapeDim,
1177        axis_offset: usize,
1178    ) {
1179        let shape = &shape_dim.shape;
1180        let ndim = shape.len();
1181        let axis = axis_offset % ndim;
1182
1183        // origin is always valid
1184        let origin = ChunkIndices(vec![0; ndim]);
1185        prop_assert!(shape.valid_chunk_coord(&origin));
1186
1187        // max valid coord is in bounds
1188        let max_valid =
1189            ChunkIndices(shape.num_chunks().map(|nc| nc.saturating_sub(1)).collect());
1190        prop_assert!(shape.valid_chunk_coord(&max_valid));
1191
1192        // bumping one axis out of bounds is rejected
1193        let mut oob = max_valid.0.clone();
1194        oob[axis] = shape.iter().nth(axis).unwrap().num_chunks().max(1);
1195        prop_assert!(!shape.valid_chunk_coord(&ChunkIndices(oob)));
1196    }
1197}