Skip to main content

icechunk_format/serializers/
mod.rs

1//! Flatbuffer serialization for Icechunk metadata.
2//!
3//! How serializers work:
4//!
5//! - Main goal is to make sure newer version of Icechunk can read metadata files created using
6//!   older versions. In this way, a repository can evolve during its life. As users upgrade their
7//!   Icechunk versions they don't need to migrate their data.
8//! - Of course we may choose to limit backwards compatibility after certain number of versions or
9//!   a time limit.
10//! - Performance is critical, so we cannot copy much data around during the process of
11//!   serialization/deserialization
12//! - For serialization:
13//!     - We define a new `XSerializer` for each metadata file type `X`. Example: `SnapshotSerializer`.
14//!     - This type implement [`serde::Serialize`]
15//!     - This type holds only references to the same fields as `X`
16//!     - This type implements `From<&X>` (notice by reference) Example:
17//!       ```ignore
18//!       impl<'a> From<&'a Snapshot> for SnapshotSerializer<'a> {
19//!       ...
20//!       }
21//!       ```
22//!     - Because the serializer only holds references it's essentially free to call
23//!       `snapshot.into()` to get one.
24//!     - Then this object is serialized using serde.
25//! - For deserialization:
26//!     - We define a new `XDeserializer` for each metadata file type `X`. Example: `SnapshotDeserializer`.
27//!     - This type implement [`serde::Deserialize`]
28//!     - This type holds the same fields as `X` by value
29//!     - `X` implements `From<XDeserializer>` (notice by value). Example:
30//!       ```ignore
31//!        impl From<SnapshotDeserializer> for Snapshot {
32//!        ...
33//!        }
34//!       ```
35//!     - Because the deserializer can be destructed and `X` implements `From`,  it's essentially free to call
36//!       obtain the original type `X`
37//!     - Then this new type `XDeserializer` is deserialized using serde and converted with `into`.
38//!
39//! - `serializers.current.rs` holds all the serializers and deserializers for the current version
40//!   of the spec
41//! - `serializers.version_foo.rs` holds all the serializers and deserializers for version foo of
42//!   the spec
43//! - The `serializers` module root has functions `serialize_X` and `deserialize_X` that take a
44//!   spec version number and use the right (de)-serializer to do the job.
45use std::io::Write;
46
47use icechunk_types::ICResultExt as _;
48
49use crate::{
50    IcechunkFormatError, IcechunkFormatErrorKind,
51    format_constants::{FileTypeBin, SpecVersionBin},
52    manifest::Manifest,
53    repo_info::RepoInfo,
54    snapshot::Snapshot,
55    transaction_log::TransactionLog,
56};
57
58pub fn serialize_snapshot(
59    snapshot: &Snapshot,
60    version: SpecVersionBin,
61    write: &mut impl Write,
62) -> Result<(), std::io::Error> {
63    match version {
64        SpecVersionBin::V1 | SpecVersionBin::V2 => write.write_all(snapshot.bytes()),
65    }
66}
67
68pub fn serialize_manifest(
69    manifest: &Manifest,
70    version: SpecVersionBin,
71    write: &mut impl Write,
72) -> Result<(), std::io::Error> {
73    match version {
74        SpecVersionBin::V1 | SpecVersionBin::V2 => write.write_all(manifest.bytes()),
75    }
76}
77
78pub fn serialize_transaction_log(
79    transaction_log: &TransactionLog,
80    version: SpecVersionBin,
81    write: &mut impl Write,
82) -> Result<(), std::io::Error> {
83    match version {
84        SpecVersionBin::V1 | SpecVersionBin::V2 => {
85            write.write_all(transaction_log.bytes())
86        }
87    }
88}
89
90pub fn serialize_repo_info(
91    info: &RepoInfo,
92    version: SpecVersionBin,
93    write: &mut impl Write,
94) -> Result<(), std::io::Error> {
95    match version {
96        SpecVersionBin::V2 => write.write_all(info.bytes()),
97        SpecVersionBin::V1 => Err(std::io::Error::new(
98            std::io::ErrorKind::Unsupported,
99            "Trying to write to an old Icechunk format version. Aborting.",
100        )),
101    }
102}
103
104pub fn deserialize_snapshot(
105    version: SpecVersionBin,
106    buffer: Vec<u8>,
107) -> Result<Snapshot, IcechunkFormatError> {
108    match version {
109        SpecVersionBin::V1 | SpecVersionBin::V2 => Snapshot::from_buffer(version, buffer),
110    }
111}
112
113pub fn deserialize_manifest(
114    version: SpecVersionBin,
115    buffer: Vec<u8>,
116) -> Result<Manifest, IcechunkFormatError> {
117    match version {
118        SpecVersionBin::V1 | SpecVersionBin::V2 => Manifest::from_buffer(buffer),
119    }
120}
121
122pub fn deserialize_transaction_log(
123    version: SpecVersionBin,
124    buffer: Vec<u8>,
125) -> Result<TransactionLog, IcechunkFormatError> {
126    match version {
127        SpecVersionBin::V1 | SpecVersionBin::V2 => TransactionLog::from_buffer(buffer),
128    }
129}
130
131pub fn deserialize_repo_info(
132    version: SpecVersionBin,
133    buffer: Vec<u8>,
134) -> Result<RepoInfo, IcechunkFormatError> {
135    match version {
136        SpecVersionBin::V2 => RepoInfo::from_buffer(buffer),
137        SpecVersionBin::V1 => {
138            Err(IcechunkFormatErrorKind::UnsupportedOperationForVersion {
139                version: SpecVersionBin::V1 as u8,
140            })
141            .capture()
142        }
143    }
144}
145
146/// Files at or above this size should be verified before being written.
147///
148/// Verification costs about as much as the zstd pass a write already does, so we
149/// only pay it for files large enough to be at risk of tripping a flatbuffers
150/// limit on read. No buffer below this size can trip one:
151///
152/// - Tables: a table costs at least [`MIN_BYTES_PER_TABLE`] bytes of buffer, so
153///   reaching the smallest `max_tables` we allow takes several times this size.
154/// - Apparent size: the verifier's counter runs at most
155///   [`MAX_APPARENT_SIZE_INFLATION`] times the buffer, because it charges every
156///   table visit for a vtable the builder stored only once.
157///
158/// The tests in this module measure both bounds against every file type and
159/// check them against the limits, so a schema change that erodes them fails
160/// there rather than silently in a repository.
161pub const VERIFY_THRESHOLD_BYTES: usize = 128 * 1024 * 1024;
162
163/// Lower bound on the buffer bytes one flatbuffers table costs: a 4 byte soffset
164/// plus its 4 byte slot in the enclosing vector. Vtables are deduplicated by the
165/// builder, so they don't count towards the floor.
166pub const MIN_BYTES_PER_TABLE: usize = 8;
167
168/// Upper bound on how much larger the verifier's apparent-size counter runs than
169/// the buffer it walks, over every element type we serialize.
170pub const MAX_APPARENT_SIZE_INFLATION: usize = 2;
171
172/// Check that `buffer` can be read back as `file_type`.
173///
174/// Applies exactly the checks the matching `from_buffer` applies on read, so a
175/// buffer that passes here cannot fail to deserialize later. File types that are
176/// not flatbuffers have nothing to check.
177pub fn verify_buffer(
178    file_type: FileTypeBin,
179    buffer: &[u8],
180) -> Result<(), IcechunkFormatError> {
181    match file_type {
182        FileTypeBin::Snapshot => Snapshot::verify_buffer(buffer),
183        FileTypeBin::Manifest => Manifest::verify_buffer(buffer),
184        FileTypeBin::TransactionLog => TransactionLog::verify_buffer(buffer),
185        FileTypeBin::RepoInfo => RepoInfo::verify_buffer(buffer),
186        FileTypeBin::Attributes | FileTypeBin::Chunk => Ok(()),
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use bytes::Bytes;
193    use flatbuffers::VerifierOptions;
194    use icechunk_types::error::ICError;
195
196    use super::*;
197    use crate::{
198        ChunkId, ChunkIndices, ManifestId, NodeId, SnapshotId,
199        format_constants::FileTypeBin,
200        manifest::{ChunkInfo, ChunkPayload, ChunkRef},
201    };
202
203    fn all_root_options() -> Vec<(&'static str, &'static VerifierOptions)> {
204        vec![
205            ("manifest", crate::manifest::root_options()),
206            ("snapshot", crate::snapshot::root_options()),
207            ("transaction log", crate::transaction_log::root_options()),
208            ("repo info", crate::repo_info::root_options()),
209        ]
210    }
211
212    fn native_payload(i: usize) -> ChunkPayload {
213        ChunkPayload::Ref(ChunkRef {
214            id: ChunkId::random(),
215            offset: i as u64,
216            length: 42,
217        })
218    }
219
220    fn virtual_payload(i: usize) -> ChunkPayload {
221        ChunkPayload::Virtual(crate::manifest::VirtualChunkRef {
222            location: crate::manifest::VirtualChunkLocation::from_url(&format!(
223                "s3://some-bucket/some/prefix/object-{i:012}"
224            ))
225            .expect("bad url"),
226            offset: i as u64,
227            length: 42,
228            checksum: None,
229        })
230    }
231
232    fn inline_payload(_i: usize) -> ChunkPayload {
233        ChunkPayload::Inline(Bytes::from(vec![42u8; 64]))
234    }
235
236    fn many_chunks_manifest(n: usize, payload: fn(usize) -> ChunkPayload) -> Vec<u8> {
237        let node = NodeId::random();
238        let chunks = (0..n)
239            .map(|i| ChunkInfo {
240                node: node.clone(),
241                coord: ChunkIndices(vec![i as u32, 0, 0]),
242                payload: payload(i),
243            })
244            .collect();
245        Manifest::from_sorted_vec(&ManifestId::random(), chunks, None)
246            .expect("cannot build manifest")
247            .expect("manifest is empty")
248            .bytes()
249            .to_vec()
250    }
251
252    fn many_nodes_snapshot(n: usize, arrays: bool) -> Vec<u8> {
253        use crate::snapshot::{ArrayShape, NodeData, NodeSnapshot};
254        let nodes: Vec<_> = (0..n)
255            .map(|i| {
256                let node_data = if arrays {
257                    NodeData::Array {
258                        shape: ArrayShape::new([(1000u64, 10u32); 3]).expect("bad shape"),
259                        dimension_names: None,
260                        manifests: vec![crate::manifest::ManifestRef {
261                            object_id: ManifestId::random(),
262                            extents: crate::manifest::ManifestExtents::new(
263                                &[0, 0, 0],
264                                &[10, 10, 10],
265                            ),
266                        }],
267                    }
268                } else {
269                    NodeData::Group
270                };
271                Ok(NodeSnapshot {
272                    id: NodeId::random(),
273                    path: crate::Path::new(&format!("/node{i:012}")).expect("bad path"),
274                    user_data: Bytes::from(vec![b'u'; 100]),
275                    node_data,
276                })
277            })
278            .collect();
279        Snapshot::from_iter(
280            None,
281            None,
282            SpecVersionBin::current(),
283            "a message",
284            None,
285            vec![],
286            None,
287            nodes,
288        )
289        .expect("cannot build snapshot")
290        .bytes()
291        .to_vec()
292    }
293
294    fn many_coords_transaction_log(n: usize) -> Vec<u8> {
295        let empty = Vec::<NodeId>::new();
296        let chunks =
297            vec![(NodeId::random(), (0..n).map(|i| ChunkIndices(vec![i as u32, 0, 0])))];
298        TransactionLog::new_from_parts(
299            &SnapshotId::random(),
300            empty.clone().into_iter(),
301            empty.clone().into_iter(),
302            empty.clone().into_iter(),
303            empty.clone().into_iter(),
304            empty.clone().into_iter(),
305            empty.into_iter(),
306            chunks.into_iter(),
307            std::iter::empty(),
308        )
309        .bytes()
310        .to_vec()
311    }
312
313    fn many_snapshots_repo_info(n: usize) -> Vec<u8> {
314        use crate::repo_info::{RepoAvailability, RepoStatus, UpdateInfo, UpdateType};
315        let snapshots: Vec<_> = (0..n)
316            .map(|i| crate::snapshot::SnapshotInfo {
317                id: SnapshotId::random(),
318                parent_id: None,
319                flushed_at: chrono::Utc::now(),
320                message: format!("commit number {i}"),
321                metadata: Default::default(),
322                pruned_ancestor_tx_logs: vec![],
323            })
324            .collect();
325        let branch_target = snapshots[0].id.clone();
326        let updates: Vec<crate::repo_info::UpdateTuple<'_>> = vec![];
327        RepoInfo::new(
328            SpecVersionBin::current(),
329            [],
330            [("main", branch_target)],
331            [],
332            snapshots,
333            &Default::default(),
334            UpdateInfo {
335                update_type: UpdateType::RepoInitializedUpdate,
336                update_time: chrono::Utc::now(),
337                previous_updates: updates,
338            },
339            None,
340            10,
341            None,
342            None,
343            None::<std::iter::Empty<u16>>,
344            None::<std::iter::Empty<u16>>,
345            &RepoStatus {
346                availability: RepoAvailability::Online,
347                set_at: chrono::Utc::now(),
348                limited_availability_reason: None,
349            },
350        )
351        .expect("cannot build repo info")
352        .bytes()
353        .to_vec()
354    }
355
356    fn a_manifest() -> Manifest {
357        Manifest::from_sorted_vec(
358            &ManifestId::random(),
359            vec![ChunkInfo {
360                node: NodeId::random(),
361                coord: ChunkIndices(vec![0, 0]),
362                payload: ChunkPayload::Ref(ChunkRef {
363                    id: ChunkId::random(),
364                    offset: 0,
365                    length: 42,
366                }),
367            }],
368            None,
369        )
370        .expect("cannot build manifest")
371        .expect("manifest is empty")
372    }
373
374    fn a_transaction_log() -> TransactionLog {
375        let empty = Vec::<NodeId>::new();
376        let no_chunks = Vec::<(NodeId, std::vec::IntoIter<ChunkIndices>)>::new();
377        TransactionLog::new_from_parts(
378            &SnapshotId::random(),
379            empty.clone().into_iter(),
380            empty.clone().into_iter(),
381            empty.clone().into_iter(),
382            empty.clone().into_iter(),
383            empty.clone().into_iter(),
384            empty.into_iter(),
385            no_chunks.into_iter(),
386            std::iter::empty(),
387        )
388    }
389
390    fn a_repo_info() -> RepoInfo {
391        RepoInfo::initial(
392            SpecVersionBin::current(),
393            crate::snapshot::SnapshotInfo {
394                id: SnapshotId::random(),
395                parent_id: None,
396                flushed_at: chrono::Utc::now(),
397                message: "initial".to_string(),
398                metadata: Default::default(),
399                pruned_ancestor_tx_logs: vec![],
400            },
401            10,
402            None::<&()>,
403            None,
404        )
405    }
406
407    #[icechunk_macros::test]
408    fn verify_buffer_accepts_buffers_we_serialize()
409    -> Result<(), Box<dyn std::error::Error>> {
410        let snapshot = Snapshot::initial(SpecVersionBin::current())?;
411        verify_buffer(FileTypeBin::Snapshot, snapshot.bytes())?;
412        verify_buffer(FileTypeBin::Manifest, a_manifest().bytes())?;
413        verify_buffer(FileTypeBin::TransactionLog, a_transaction_log().bytes())?;
414        verify_buffer(FileTypeBin::RepoInfo, a_repo_info().bytes())?;
415        Ok(())
416    }
417
418    #[icechunk_macros::test]
419    fn verify_buffer_rejects_buffers_that_cannot_be_read_back() {
420        // a valid flatbuffer of the wrong type, and plain garbage
421        let snapshot_bytes = a_manifest().bytes().to_vec();
422        for file_type in [
423            FileTypeBin::Snapshot,
424            FileTypeBin::Manifest,
425            FileTypeBin::TransactionLog,
426            FileTypeBin::RepoInfo,
427        ] {
428            for bad in [vec![0u8; 64], vec![0xffu8; 7], snapshot_bytes[..32].to_vec()] {
429                assert!(
430                    verify_buffer(file_type, bad.as_slice()).is_err(),
431                    "{file_type:?} accepted a buffer it cannot read back"
432                );
433            }
434        }
435    }
436
437    #[icechunk_macros::test]
438    fn verify_buffer_ignores_file_types_that_are_not_flatbuffers() {
439        // chunks are opaque bytes, there is nothing to verify
440        let chunk = Bytes::from_static(b"not a flatbuffer");
441        assert!(verify_buffer(FileTypeBin::Chunk, chunk.as_ref()).is_ok());
442    }
443
444    /// Runs a root type's verifier over a buffer with the given limits.
445    type VerifyFn = Box<dyn Fn(&[u8], &VerifierOptions) -> bool>;
446
447    /// A buffer to measure, named, with the verifier for its root type.
448    type Sample = (&'static str, Vec<u8>, VerifyFn);
449
450    /// What the verifier counted while walking `buffer`, found by searching for
451    /// the smallest limit that still lets it through.
452    struct Counters {
453        apparent_size: usize,
454        tables: usize,
455        bytes: usize,
456    }
457
458    fn min_limit_that_passes(hi: usize, passes: impl Fn(usize) -> bool) -> usize {
459        assert!(passes(hi), "no limit in range lets the buffer through");
460        let (mut lo, mut hi) = (0usize, hi);
461        while lo < hi {
462            let mid = lo + (hi - lo) / 2;
463            if passes(mid) {
464                hi = mid;
465            } else {
466                lo = mid + 1;
467            }
468        }
469        lo
470    }
471
472    fn unlimited() -> VerifierOptions {
473        VerifierOptions {
474            max_depth: 1 << 20,
475            max_tables: 1 << 40,
476            max_apparent_size: 1 << 50,
477            ignore_missing_null_terminator: true,
478        }
479    }
480
481    fn with_apparent_size(limit: usize) -> VerifierOptions {
482        VerifierOptions { max_apparent_size: limit, ..unlimited() }
483    }
484
485    fn with_tables(limit: usize) -> VerifierOptions {
486        VerifierOptions { max_tables: limit, ..unlimited() }
487    }
488
489    fn count(verify: &VerifyFn, buffer: &[u8]) -> Counters {
490        assert!(verify(buffer, &unlimited()), "buffer does not verify at all");
491        Counters {
492            apparent_size: min_limit_that_passes(1 << 40, |limit| {
493                verify(buffer, &with_apparent_size(limit))
494            }),
495            tables: min_limit_that_passes(1 << 32, |limit| {
496                verify(buffer, &with_tables(limit))
497            }),
498            bytes: buffer.len(),
499        }
500    }
501
502    /// One buffer per element type we serialize, each with enough elements that
503    /// per-element costs dominate the root table's fixed overhead.
504    fn sample_buffers() -> Vec<Sample> {
505        let n = 5_000;
506        let manifest_verify = || {
507            Box::new(|b: &[u8], o: &VerifierOptions| {
508                flatbuffers::root_with_opts::<crate::flatbuffers::generated::Manifest<'_>>(
509                    o, b,
510                )
511                .is_ok()
512            })
513        };
514        vec![
515            (
516                "manifest native refs",
517                many_chunks_manifest(n, native_payload),
518                manifest_verify(),
519            ),
520            (
521                "manifest virtual refs",
522                many_chunks_manifest(n, virtual_payload),
523                manifest_verify(),
524            ),
525            (
526                "manifest inline refs",
527                many_chunks_manifest(n, inline_payload),
528                manifest_verify(),
529            ),
530            (
531                "snapshot groups",
532                many_nodes_snapshot(n, false),
533                Box::new(|b: &[u8], o: &VerifierOptions| {
534                    flatbuffers::root_with_opts::<
535                        crate::flatbuffers::generated::Snapshot<'_>,
536                    >(o, b)
537                    .is_ok()
538                }),
539            ),
540            (
541                "snapshot arrays",
542                many_nodes_snapshot(n, true),
543                Box::new(|b: &[u8], o: &VerifierOptions| {
544                    flatbuffers::root_with_opts::<
545                        crate::flatbuffers::generated::Snapshot<'_>,
546                    >(o, b)
547                    .is_ok()
548                }),
549            ),
550            (
551                "transaction log chunk coords",
552                many_coords_transaction_log(n),
553                Box::new(|b: &[u8], o: &VerifierOptions| {
554                    flatbuffers::root_with_opts::<
555                        crate::flatbuffers::generated::TransactionLog<'_>,
556                    >(o, b)
557                    .is_ok()
558                }),
559            ),
560            (
561                "repo snapshot infos",
562                many_snapshots_repo_info(n),
563                Box::new(|b: &[u8], o: &VerifierOptions| {
564                    flatbuffers::root_with_opts::<crate::flatbuffers::generated::Repo<'_>>(
565                        o, b,
566                    )
567                    .is_ok()
568                }),
569            ),
570        ]
571    }
572
573    #[icechunk_macros::test]
574    fn apparent_size_stays_within_the_inflation_bound() {
575        for (name, buffer, verify) in sample_buffers() {
576            let counted = count(&verify, buffer.as_slice());
577            assert!(
578                counted.apparent_size <= MAX_APPARENT_SIZE_INFLATION * counted.bytes,
579                "{name}: apparent size {} is more than {MAX_APPARENT_SIZE_INFLATION}x the {} bytes it walks",
580                counted.apparent_size,
581                counted.bytes,
582            );
583        }
584    }
585
586    #[icechunk_macros::test]
587    fn tables_cost_at_least_the_assumed_minimum_bytes() {
588        for (name, buffer, verify) in sample_buffers() {
589            let counted = count(&verify, buffer.as_slice());
590            assert!(
591                counted.bytes >= MIN_BYTES_PER_TABLE * counted.tables,
592                "{name}: {} tables in only {} bytes, under {MIN_BYTES_PER_TABLE} bytes each",
593                counted.tables,
594                counted.bytes,
595            );
596        }
597    }
598
599    #[icechunk_macros::test]
600    fn verifier_limits_leave_room_below_the_write_threshold() {
601        for (name, options) in all_root_options() {
602            // a file under the write threshold is never verified before writing,
603            // so it must not be able to trip either limit on read
604            assert!(
605                MAX_APPARENT_SIZE_INFLATION * VERIFY_THRESHOLD_BYTES
606                    <= options.max_apparent_size,
607                "{name}: a file just under the write threshold could exceed max_apparent_size",
608            );
609            assert!(
610                VERIFY_THRESHOLD_BYTES / MIN_BYTES_PER_TABLE <= options.max_tables,
611                "{name}: a file just under the write threshold could exceed max_tables",
612            );
613        }
614    }
615
616    #[icechunk_macros::test]
617    fn every_file_we_can_build_can_be_read_back() {
618        // the builder refuses to grow past FLATBUFFERS_MAX_BUFFER_SIZE, so a file
619        // at that size is the largest we can produce: the verifier has to accept it
620        for (name, options) in all_root_options() {
621            assert!(
622                MAX_APPARENT_SIZE_INFLATION
623                    .saturating_mul(flatbuffers::FLATBUFFERS_MAX_BUFFER_SIZE)
624                    <= options.max_apparent_size,
625                "{name}: a file we can build could be rejected by max_apparent_size",
626            );
627        }
628    }
629
630    #[icechunk_macros::test]
631    fn verify_buffer_error_names_the_file_type() {
632        let err = verify_buffer(FileTypeBin::Manifest, &[0u8; 64])
633            .expect_err("garbage must not verify");
634        assert!(matches!(
635            err,
636            ICError { kind: IcechunkFormatErrorKind::InvalidFlatBuffer(_), .. }
637        ));
638    }
639}