Skip to main content

reth_nippy_jar/
lib.rs

1//! Immutable data store format.
2//!
3//! *Warning*: The `NippyJar` encoding format and its implementations are
4//! designed for storing and retrieving data internally. They are not hardened
5//! to safely read potentially malicious data.
6
7#![doc(
8    html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png",
9    html_favicon_url = "https://avatars0.githubusercontent.com/u/97369466?s=256",
10    issue_tracker_base_url = "https://github.com/paradigmxyz/reth/issues/"
11)]
12#![cfg_attr(not(test), warn(unused_crate_dependencies))]
13#![cfg_attr(docsrs, feature(doc_cfg))]
14
15use memmap2::Mmap;
16use serde::{Deserialize, Serialize};
17use smallvec::SmallVec;
18use std::{
19    error::Error as StdError,
20    fs::File,
21    io::{self, Read, Write},
22    ops::Range,
23    path::{Path, PathBuf},
24};
25use tracing::*;
26
27/// Compression algorithms supported by `NippyJar`.
28pub mod compression;
29#[cfg(test)]
30use compression::Compression;
31use compression::Compressors;
32
33/// empty enum for backwards compatibility
34#[derive(Debug, Serialize, Deserialize)]
35#[cfg_attr(test, derive(PartialEq, Eq))]
36pub enum Functions {}
37
38/// empty enum for backwards compatibility
39#[derive(Debug, Serialize, Deserialize)]
40#[cfg_attr(test, derive(PartialEq, Eq))]
41pub enum InclusionFilters {}
42
43mod error;
44pub use error::NippyJarError;
45
46mod cursor;
47pub use cursor::NippyJarCursor;
48
49mod writer;
50pub use writer::NippyJarWriter;
51
52mod consistency;
53pub use consistency::NippyJarChecker;
54
55/// The version number of the Nippy Jar format.
56const NIPPY_JAR_VERSION: usize = 1;
57/// The file extension used for index files.
58const INDEX_FILE_EXTENSION: &str = "idx";
59/// The file extension used for offsets files.
60const OFFSETS_FILE_EXTENSION: &str = "off";
61/// The file extension used for configuration files.
62pub const CONFIG_FILE_EXTENSION: &str = "conf";
63/// The file extension used for changeset offset sidecar files.
64pub const CHANGESET_OFFSETS_FILE_EXTENSION: &str = "csoff";
65
66/// A [`RefRow`] is a list of column value slices pointing to either an internal buffer or a
67/// memory-mapped file.
68///
69/// The inline capacity covers every segment used by reth (at most three columns), so reading a row
70/// does not allocate.
71pub type RefRow<'a> = SmallVec<[&'a [u8]; 4]>;
72
73/// Alias type for a column value wrapped in `Result`.
74pub type ColumnResult<T> = Result<T, Box<dyn StdError + Send + Sync>>;
75
76/// A trait for the user-defined header of [`NippyJar`].
77pub trait NippyJarHeader:
78    Send + Sync + Serialize + for<'b> Deserialize<'b> + std::fmt::Debug + 'static
79{
80}
81
82// Blanket implementation for all types that implement the required traits.
83impl<T> NippyJarHeader for T where
84    T: Send + Sync + Serialize + for<'b> Deserialize<'b> + std::fmt::Debug + 'static
85{
86}
87
88/// `NippyJar` is a specialized storage format designed for immutable data.
89///
90/// Data is organized into a columnar format, enabling column-based compression. Data retrieval
91/// entails consulting an offset list and fetching the data from file via `mmap`.
92#[derive(Serialize, Deserialize)]
93#[cfg_attr(test, derive(PartialEq))]
94pub struct NippyJar<H = ()> {
95    /// The version of the `NippyJar` format.
96    version: usize,
97    /// User-defined header data.
98    /// Default: zero-sized unit type: no header data
99    user_header: H,
100    /// Number of data columns in the jar.
101    columns: usize,
102    /// Number of data rows in the jar.
103    rows: usize,
104    /// Optional compression algorithm applied to the data.
105    compressor: Option<Compressors>,
106    #[serde(skip)]
107    /// Optional field for backwards compatibility
108    filter: Option<InclusionFilters>,
109    #[serde(skip)]
110    /// Optional field for backwards compatibility
111    phf: Option<Functions>,
112    /// Maximum uncompressed row size of the set. This will enable decompression without any
113    /// resizing of the output buffer.
114    max_row_size: usize,
115    /// Data path for file. Supporting files will have a format `{path}.{extension}`.
116    #[serde(skip)]
117    path: PathBuf,
118}
119
120impl<H: NippyJarHeader> std::fmt::Debug for NippyJar<H> {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("NippyJar")
123            .field("version", &self.version)
124            .field("user_header", &self.user_header)
125            .field("rows", &self.rows)
126            .field("columns", &self.columns)
127            .field("compressor", &self.compressor)
128            .field("filter", &self.filter)
129            .field("phf", &self.phf)
130            .field("path", &self.path)
131            .field("max_row_size", &self.max_row_size)
132            .finish_non_exhaustive()
133    }
134}
135
136impl NippyJar<()> {
137    /// Creates a new [`NippyJar`] without an user-defined header data.
138    pub fn new_without_header(columns: usize, path: &Path) -> Self {
139        Self::new(columns, path, ())
140    }
141
142    /// Loads the file configuration and returns [`Self`] on a jar without user-defined header data.
143    pub fn load_without_header(path: &Path) -> Result<Self, NippyJarError> {
144        Self::load(path)
145    }
146}
147
148impl<H: NippyJarHeader> NippyJar<H> {
149    /// Creates a new [`NippyJar`] with a user-defined header data.
150    pub fn new(columns: usize, path: &Path, user_header: H) -> Self {
151        Self {
152            version: NIPPY_JAR_VERSION,
153            user_header,
154            columns,
155            rows: 0,
156            max_row_size: 0,
157            compressor: None,
158            filter: None,
159            phf: None,
160            path: path.to_path_buf(),
161        }
162    }
163
164    /// Adds [`compression::Zstd`] compression.
165    pub fn with_zstd(mut self, use_dict: bool, max_dict_size: usize) -> Self {
166        self.compressor =
167            Some(Compressors::Zstd(compression::Zstd::new(use_dict, max_dict_size, self.columns)));
168        self
169    }
170
171    /// Adds [`compression::Lz4`] compression.
172    pub fn with_lz4(mut self) -> Self {
173        self.compressor = Some(Compressors::Lz4(compression::Lz4::default()));
174        self
175    }
176
177    /// Gets a reference to the user header.
178    pub const fn user_header(&self) -> &H {
179        &self.user_header
180    }
181
182    /// Gets total columns in jar.
183    pub const fn columns(&self) -> usize {
184        self.columns
185    }
186
187    /// Gets total rows in jar.
188    pub const fn rows(&self) -> usize {
189        self.rows
190    }
191
192    /// Gets a reference to the compressor.
193    pub const fn compressor(&self) -> Option<&Compressors> {
194        self.compressor.as_ref()
195    }
196
197    /// Gets a mutable reference to the compressor.
198    pub const fn compressor_mut(&mut self) -> Option<&mut Compressors> {
199        self.compressor.as_mut()
200    }
201
202    /// Loads the file configuration and returns [`Self`].
203    ///
204    /// **The user must ensure the header type matches the one used during the jar's creation.**
205    pub fn load(path: &Path) -> Result<Self, NippyJarError> {
206        // Read [`Self`] located at the data file.
207        let config_path = path.with_extension(CONFIG_FILE_EXTENSION);
208        let config_file = File::open(&config_path)
209            .inspect_err(|e| {
210                warn!(?path, %e, "Failed to load static file jar");
211            })
212            .map_err(|err| reth_fs_util::FsPathError::open(err, config_path))?;
213
214        let mut obj = Self::load_from_reader(io::BufReader::new(config_file))?;
215        obj.path = path.to_path_buf();
216        Ok(obj)
217    }
218
219    /// Deserializes an instance of [`Self`] from a [`Read`] type.
220    pub fn load_from_reader<R: Read>(reader: R) -> Result<Self, NippyJarError> {
221        Ok(bincode::deserialize_from(reader)?)
222    }
223
224    /// Serializes an instance of [`Self`] to a [`Write`] type.
225    pub fn save_to_writer<W: Write>(&self, writer: W) -> Result<(), NippyJarError> {
226        Ok(bincode::serialize_into(writer, self)?)
227    }
228
229    /// Returns the path for the data file
230    pub fn data_path(&self) -> &Path {
231        self.path.as_ref()
232    }
233
234    /// Returns the path for the index file
235    pub fn index_path(&self) -> PathBuf {
236        self.path.with_extension(INDEX_FILE_EXTENSION)
237    }
238
239    /// Returns the path for the offsets file
240    pub fn offsets_path(&self) -> PathBuf {
241        self.path.with_extension(OFFSETS_FILE_EXTENSION)
242    }
243
244    /// Returns the path for the config file
245    pub fn config_path(&self) -> PathBuf {
246        self.path.with_extension(CONFIG_FILE_EXTENSION)
247    }
248
249    /// Returns the path for the changeset offsets sidecar file.
250    pub fn changeset_offsets_path(&self) -> PathBuf {
251        self.path.with_extension(CHANGESET_OFFSETS_FILE_EXTENSION)
252    }
253
254    /// Deletes from disk this [`NippyJar`] alongside every satellite file.
255    pub fn delete(self) -> Result<(), NippyJarError> {
256        // TODO(joshie): ensure consistency on unexpected shutdown
257
258        for path in [
259            self.data_path().into(),
260            self.index_path(),
261            self.offsets_path(),
262            self.config_path(),
263            self.changeset_offsets_path(),
264        ] {
265            if path.exists() {
266                debug!(target: "nippy-jar", ?path, "Removing file.");
267                reth_fs_util::remove_file(path)?;
268            }
269        }
270
271        Ok(())
272    }
273
274    /// Returns a [`DataReader`] of the data and offset file
275    pub fn open_data_reader(&self) -> Result<DataReader, NippyJarError> {
276        DataReader::new(self.data_path())
277    }
278
279    /// Writes all necessary configuration to file.
280    fn freeze_config(&self) -> Result<(), NippyJarError> {
281        Ok(reth_fs_util::atomic_write_file(&self.config_path(), |file| self.save_to_writer(file))?)
282    }
283}
284
285#[cfg(test)]
286impl<H: NippyJarHeader> NippyJar<H> {
287    /// If required, prepares any compression algorithm to an early pass of the data.
288    pub fn prepare_compression(
289        &mut self,
290        columns: Vec<impl IntoIterator<Item = Vec<u8>>>,
291    ) -> Result<(), NippyJarError> {
292        // Makes any necessary preparations for the compressors
293        if let Some(compression) = &mut self.compressor {
294            debug!(target: "nippy-jar", columns=columns.len(), "Preparing compression.");
295            compression.prepare_compression(columns)?;
296        }
297        Ok(())
298    }
299
300    /// Writes all data and configuration to a file and the offset index to another.
301    pub fn freeze(
302        self,
303        columns: Vec<impl IntoIterator<Item = ColumnResult<Vec<u8>>>>,
304        total_rows: u64,
305    ) -> Result<Self, NippyJarError> {
306        self.check_before_freeze(&columns)?;
307
308        debug!(target: "nippy-jar", path=?self.data_path(), "Opening data file.");
309
310        // Creates the writer, data and offsets file
311        let mut writer = NippyJarWriter::new(self)?;
312
313        // Append rows to file while holding offsets in memory
314        writer.append_rows(columns, total_rows)?;
315
316        // Flushes configuration and offsets to disk
317        writer.commit()?;
318
319        debug!(target: "nippy-jar", ?writer, "Finished writing data.");
320
321        Ok(writer.into_jar())
322    }
323
324    /// Safety checks before creating and returning a [`File`] handle to write data to.
325    fn check_before_freeze(
326        &self,
327        columns: &[impl IntoIterator<Item = ColumnResult<Vec<u8>>>],
328    ) -> Result<(), NippyJarError> {
329        if columns.len() != self.columns {
330            return Err(NippyJarError::ColumnLenMismatch(self.columns, columns.len()))
331        }
332
333        if let Some(compression) = &self.compressor &&
334            !compression.is_ready()
335        {
336            return Err(NippyJarError::CompressorNotReady)
337        }
338
339        Ok(())
340    }
341}
342
343/// Manages the reading of static file data using memory-mapped files.
344///
345/// Holds file and mmap descriptors of the data and offsets files of a `static_file`.
346#[derive(Debug)]
347pub struct DataReader {
348    /// Data file descriptor. Needs to be kept alive as long as `data_mmap` handle.
349    #[expect(dead_code)]
350    data_file: File,
351    /// Mmap handle for data.
352    data_mmap: Mmap,
353    /// Offset file descriptor. Needs to be kept alive as long as `offset_mmap` handle.
354    offset_file: File,
355    /// Mmap handle for offsets.
356    offset_mmap: Mmap,
357    /// Number of bytes that represent one offset.
358    offset_size: u8,
359}
360
361impl DataReader {
362    /// Reads the respective data and offsets file and returns [`DataReader`].
363    pub fn new(path: impl AsRef<Path>) -> Result<Self, NippyJarError> {
364        let data_file = File::open(path.as_ref())?;
365        // SAFETY: File is read-only and its descriptor is kept alive as long as the mmap handle.
366        let data_mmap = unsafe { Mmap::map(&data_file)? };
367
368        let offset_file = File::open(path.as_ref().with_extension(OFFSETS_FILE_EXTENSION))?;
369        // SAFETY: File is read-only and its descriptor is kept alive as long as the mmap handle.
370        let offset_mmap = unsafe { Mmap::map(&offset_file)? };
371
372        // First byte is the size of one offset in bytes
373        let offset_size = offset_mmap[0];
374
375        // Ensure that the size of an offset is at most 8 bytes.
376        if offset_size > 8 {
377            return Err(NippyJarError::OffsetSizeTooBig { offset_size })
378        } else if offset_size == 0 {
379            return Err(NippyJarError::OffsetSizeTooSmall { offset_size })
380        }
381
382        Ok(Self { data_file, data_mmap, offset_file, offset_size, offset_mmap })
383    }
384
385    /// Returns the offset for the requested data index
386    pub fn offset(&self, index: usize) -> Result<u64, NippyJarError> {
387        // + 1 represents the offset_len u8 which is in the beginning of the file
388        let from = index * self.offset_size as usize + 1;
389
390        self.offset_at(from)
391    }
392
393    /// Returns the offset for the requested data index starting from the end
394    pub fn reverse_offset(&self, index: usize) -> Result<u64, NippyJarError> {
395        let offsets_file_size = self.offset_file.metadata()?.len() as usize;
396
397        if offsets_file_size > 1 {
398            let from = offsets_file_size - self.offset_size as usize * (index + 1);
399
400            self.offset_at(from)
401        } else {
402            Ok(0)
403        }
404    }
405
406    /// Returns total number of offsets in the file.
407    /// The size of one offset is determined by the file itself.
408    pub fn offsets_count(&self) -> Result<usize, NippyJarError> {
409        Ok((self.offset_file.metadata()?.len().saturating_sub(1) / self.offset_size as u64)
410            as usize)
411    }
412
413    /// Reads one offset-sized (determined by the offset file) u64 at the provided index.
414    fn offset_at(&self, index: usize) -> Result<u64, NippyJarError> {
415        let mut buffer: [u8; 8] = [0; 8];
416
417        let offset_end = index.saturating_add(self.offset_size as usize);
418        if offset_end > self.offset_mmap.len() {
419            return Err(NippyJarError::OffsetOutOfBounds { index })
420        }
421
422        buffer[..self.offset_size as usize].copy_from_slice(&self.offset_mmap[index..offset_end]);
423        Ok(u64::from_le_bytes(buffer))
424    }
425
426    /// Returns number of bytes that represent one offset.
427    pub const fn offset_size(&self) -> u8 {
428        self.offset_size
429    }
430
431    /// Returns the underlying data as a slice of bytes for the provided range.
432    pub fn data(&self, range: Range<usize>) -> &[u8] {
433        &self.data_mmap[range]
434    }
435
436    /// Returns total size of data file.
437    pub fn size(&self) -> usize {
438        self.data_mmap.len()
439    }
440
441    /// Returns total size of offsets file.
442    pub fn offsets_size(&self) -> usize {
443        self.offset_mmap.len()
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use compression::Compression;
451    use rand::{rngs::SmallRng, seq::SliceRandom, RngCore, SeedableRng};
452    use std::{fs::OpenOptions, io::Read};
453
454    type ColumnResults<T> = Vec<ColumnResult<T>>;
455    type ColumnValues = Vec<Vec<u8>>;
456
457    fn test_data(seed: Option<u64>) -> (ColumnValues, ColumnValues) {
458        let value_length = 32;
459        let num_rows = 100;
460
461        let mut vec: Vec<u8> = vec![0; value_length];
462        let mut rng = seed.map(SmallRng::seed_from_u64).unwrap_or_else(SmallRng::from_os_rng);
463
464        let mut entry_gen = || {
465            (0..num_rows)
466                .map(|_| {
467                    rng.fill_bytes(&mut vec[..]);
468                    vec.clone()
469                })
470                .collect()
471        };
472
473        (entry_gen(), entry_gen())
474    }
475
476    fn clone_with_result(col: &ColumnValues) -> ColumnResults<Vec<u8>> {
477        col.iter().map(|v| Ok(v.clone())).collect()
478    }
479
480    #[test]
481    fn test_config_serialization() {
482        let file = tempfile::NamedTempFile::new().unwrap();
483        let jar = NippyJar::new_without_header(23, file.path()).with_lz4();
484        jar.freeze_config().unwrap();
485
486        let mut config_file = OpenOptions::new().read(true).open(jar.config_path()).unwrap();
487        let config_file_len = config_file.metadata().unwrap().len();
488        assert_eq!(config_file_len, 37);
489
490        let mut buf = Vec::with_capacity(config_file_len as usize);
491        config_file.read_to_end(&mut buf).unwrap();
492
493        assert_eq!(
494            vec![
495                1, 0, 0, 0, 0, 0, 0, 0, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0,
496                0, 0, 0, 0, 0, 0, 0, 0, 0, 0
497            ],
498            buf
499        );
500
501        let mut read_jar = bincode::deserialize_from::<_, NippyJar>(&buf[..]).unwrap();
502        // Path is not ser/de
503        read_jar.path = file.path().to_path_buf();
504        assert_eq!(jar, read_jar);
505    }
506
507    #[test]
508    fn test_zstd_with_dictionaries() {
509        let (col1, col2) = test_data(None);
510        let num_rows = col1.len() as u64;
511        let num_columns = 2;
512        let file_path = tempfile::NamedTempFile::new().unwrap();
513
514        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
515        assert!(nippy.compressor().is_none());
516
517        let mut nippy =
518            NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(true, 5000);
519        assert!(nippy.compressor().is_some());
520
521        if let Some(Compressors::Zstd(zstd)) = &mut nippy.compressor_mut() {
522            assert!(matches!(zstd.compressors(), Err(NippyJarError::CompressorNotReady)));
523
524            // Make sure the number of column iterators match the initial set up ones.
525            assert!(matches!(
526                zstd.prepare_compression(vec![col1.clone(), col2.clone(), col2.clone()]),
527                Err(NippyJarError::ColumnLenMismatch(columns, 3)) if columns == num_columns
528            ));
529        }
530
531        // If ZSTD is enabled, do not write to the file unless the column dictionaries have been
532        // calculated.
533        assert!(matches!(
534            nippy.freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows),
535            Err(NippyJarError::CompressorNotReady)
536        ));
537
538        let mut nippy =
539            NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(true, 5000);
540        assert!(nippy.compressor().is_some());
541
542        nippy.prepare_compression(vec![col1.clone(), col2.clone()]).unwrap();
543
544        if let Some(Compressors::Zstd(zstd)) = &nippy.compressor() {
545            assert!(matches!(
546                (&zstd.state, zstd.dictionaries.as_ref().map(|dict| dict.len())),
547                (compression::ZstdState::Ready, Some(columns)) if columns == num_columns
548            ));
549        }
550
551        let nippy = nippy
552            .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
553            .unwrap();
554
555        let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
556        assert_eq!(nippy.version, loaded_nippy.version);
557        assert_eq!(nippy.columns, loaded_nippy.columns);
558        assert_eq!(nippy.filter, loaded_nippy.filter);
559        assert_eq!(nippy.phf, loaded_nippy.phf);
560        assert_eq!(nippy.max_row_size, loaded_nippy.max_row_size);
561        assert_eq!(nippy.path, loaded_nippy.path);
562
563        if let Some(Compressors::Zstd(zstd)) = loaded_nippy.compressor() {
564            assert!(zstd.use_dict);
565            let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
566
567            // Iterate over compressed values and compare
568            let mut row_index = 0usize;
569            while let Some(row) = cursor.next_row().unwrap() {
570                assert_eq!(
571                    (row[0], row[1]),
572                    (col1[row_index].as_slice(), col2[row_index].as_slice())
573                );
574                row_index += 1;
575            }
576        } else {
577            panic!("Expected Zstd compressor")
578        }
579    }
580
581    #[test]
582    fn test_lz4() {
583        let (col1, col2) = test_data(None);
584        let num_rows = col1.len() as u64;
585        let num_columns = 2;
586        let file_path = tempfile::NamedTempFile::new().unwrap();
587
588        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
589        assert!(nippy.compressor().is_none());
590
591        let nippy = NippyJar::new_without_header(num_columns, file_path.path()).with_lz4();
592        assert!(nippy.compressor().is_some());
593
594        let nippy = nippy
595            .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
596            .unwrap();
597
598        let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
599        assert_eq!(nippy, loaded_nippy);
600
601        if let Some(Compressors::Lz4(_)) = loaded_nippy.compressor() {
602            let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
603
604            // Iterate over compressed values and compare
605            let mut row_index = 0usize;
606            while let Some(row) = cursor.next_row().unwrap() {
607                assert_eq!(
608                    (row[0], row[1]),
609                    (col1[row_index].as_slice(), col2[row_index].as_slice())
610                );
611                row_index += 1;
612            }
613        } else {
614            panic!("Expected Lz4 compressor")
615        }
616    }
617
618    #[test]
619    fn test_zstd_no_dictionaries() {
620        let (col1, col2) = test_data(None);
621        let num_rows = col1.len() as u64;
622        let num_columns = 2;
623        let file_path = tempfile::NamedTempFile::new().unwrap();
624
625        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
626        assert!(nippy.compressor().is_none());
627
628        let nippy =
629            NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(false, 5000);
630        assert!(nippy.compressor().is_some());
631
632        let nippy = nippy
633            .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
634            .unwrap();
635
636        let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
637        assert_eq!(nippy, loaded_nippy);
638
639        if let Some(Compressors::Zstd(zstd)) = loaded_nippy.compressor() {
640            assert!(!zstd.use_dict);
641
642            let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
643
644            // Iterate over compressed values and compare
645            let mut row_index = 0usize;
646            while let Some(row) = cursor.next_row().unwrap() {
647                assert_eq!(
648                    (row[0], row[1]),
649                    (col1[row_index].as_slice(), col2[row_index].as_slice())
650                );
651                row_index += 1;
652            }
653        } else {
654            panic!("Expected Zstd compressor")
655        }
656    }
657
658    /// Tests `NippyJar` with everything enabled.
659    #[test]
660    fn test_full_nippy_jar() {
661        let (col1, col2) = test_data(None);
662        let num_rows = col1.len() as u64;
663        let num_columns = 2;
664        let file_path = tempfile::NamedTempFile::new().unwrap();
665        let data = vec![col1.clone(), col2.clone()];
666
667        let block_start = 500;
668
669        #[derive(Serialize, Deserialize, Debug)]
670        struct BlockJarHeader {
671            block_start: usize,
672        }
673
674        // Create file
675        {
676            let mut nippy =
677                NippyJar::new(num_columns, file_path.path(), BlockJarHeader { block_start })
678                    .with_zstd(true, 5000);
679
680            nippy.prepare_compression(data.clone()).unwrap();
681            nippy
682                .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
683                .unwrap();
684        }
685
686        // Read file
687        {
688            let loaded_nippy = NippyJar::<BlockJarHeader>::load(file_path.path()).unwrap();
689
690            assert!(loaded_nippy.compressor().is_some());
691            assert_eq!(loaded_nippy.user_header().block_start, block_start);
692
693            if let Some(Compressors::Zstd(_zstd)) = loaded_nippy.compressor() {
694                let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
695
696                // Iterate over compressed values and compare
697                let mut row_num = 0usize;
698                while let Some(row) = cursor.next_row().unwrap() {
699                    assert_eq!(
700                        (row[0], row[1]),
701                        (data[0][row_num].as_slice(), data[1][row_num].as_slice())
702                    );
703                    row_num += 1;
704                }
705
706                // Shuffled for chaos.
707                let mut data = col1.iter().zip(col2.iter()).enumerate().collect::<Vec<_>>();
708                data.shuffle(&mut rand::rng());
709
710                for (row_num, (v0, v1)) in data {
711                    // Simulates `by_number` queries
712                    let row_by_num = cursor.row_by_number(row_num).unwrap().unwrap();
713                    assert_eq!((&row_by_num[0].to_vec(), &row_by_num[1].to_vec()), (v0, v1));
714                }
715            }
716        }
717    }
718
719    #[test]
720    fn test_selectable_column_values() {
721        let (col1, col2) = test_data(None);
722        let num_rows = col1.len() as u64;
723        let num_columns = 2;
724        let file_path = tempfile::NamedTempFile::new().unwrap();
725        let data = vec![col1.clone(), col2.clone()];
726
727        // Create file
728        {
729            let mut nippy =
730                NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(true, 5000);
731            nippy.prepare_compression(data).unwrap();
732            nippy
733                .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
734                .unwrap();
735        }
736
737        // Read file
738        {
739            let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
740
741            if let Some(Compressors::Zstd(_zstd)) = loaded_nippy.compressor() {
742                let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
743
744                // Shuffled for chaos.
745                let mut data = col1.iter().zip(col2.iter()).enumerate().collect::<Vec<_>>();
746                data.shuffle(&mut rand::rng());
747
748                // Imagine `Blocks` static file has two columns: `Block | StoredWithdrawals`
749                const BLOCKS_FULL_MASK: usize = 0b11;
750
751                // Read both columns
752                for (row_num, (v0, v1)) in &data {
753                    // Simulates `by_number` queries
754                    let row_by_num = cursor
755                        .row_by_number_with_cols(*row_num, BLOCKS_FULL_MASK)
756                        .unwrap()
757                        .unwrap();
758                    assert_eq!((&row_by_num[0].to_vec(), &row_by_num[1].to_vec()), (*v0, *v1));
759                }
760
761                // Read first column only: `Block`
762                const BLOCKS_BLOCK_MASK: usize = 0b01;
763                for (row_num, (v0, _)) in &data {
764                    // Simulates `by_number` queries
765                    let row_by_num = cursor
766                        .row_by_number_with_cols(*row_num, BLOCKS_BLOCK_MASK)
767                        .unwrap()
768                        .unwrap();
769                    assert_eq!(row_by_num.len(), 1);
770                    assert_eq!(&row_by_num[0].to_vec(), *v0);
771                }
772
773                // Read second column only: `Block`
774                const BLOCKS_WITHDRAWAL_MASK: usize = 0b10;
775                for (row_num, (_, v1)) in &data {
776                    // Simulates `by_number` queries
777                    let row_by_num = cursor
778                        .row_by_number_with_cols(*row_num, BLOCKS_WITHDRAWAL_MASK)
779                        .unwrap()
780                        .unwrap();
781                    assert_eq!(row_by_num.len(), 1);
782                    assert_eq!(&row_by_num[0].to_vec(), *v1);
783                }
784
785                // Read nothing
786                const BLOCKS_EMPTY_MASK: usize = 0b00;
787                for (row_num, _) in &data {
788                    // Simulates `by_number` queries
789                    assert!(cursor
790                        .row_by_number_with_cols(*row_num, BLOCKS_EMPTY_MASK)
791                        .unwrap()
792                        .unwrap()
793                        .is_empty());
794                }
795            }
796        }
797    }
798
799    #[test]
800    fn test_writer() {
801        let (col1, col2) = test_data(None);
802        let num_columns = 2;
803        let file_path = tempfile::NamedTempFile::new().unwrap();
804
805        append_two_rows(num_columns, file_path.path(), &col1, &col2);
806
807        // Appends a third row and prunes two rows, to make sure we prune from memory and disk
808        // offset list
809        prune_rows(num_columns, file_path.path(), &col1, &col2);
810
811        // Should be able to append new rows
812        append_two_rows(num_columns, file_path.path(), &col1, &col2);
813
814        // Simulate an unexpected shutdown before there's a chance to commit, and see that it
815        // unwinds successfully
816        test_append_consistency_no_commit(file_path.path(), &col1, &col2);
817
818        // Simulate an unexpected shutdown during commit, and see that it unwinds successfully
819        test_append_consistency_partial_commit(file_path.path(), &col1, &col2);
820    }
821
822    #[test]
823    fn test_pruner() {
824        let (col1, col2) = test_data(None);
825        let num_columns = 2;
826        let num_rows = 2;
827
828        // (missing_offsets, expected number of rows)
829        // If a row wasn't fully pruned, then it should clear it up as well
830        let missing_offsets_scenarios = [(1, 1), (2, 1), (3, 0)];
831
832        for (missing_offsets, expected_rows) in missing_offsets_scenarios {
833            let file_path = tempfile::NamedTempFile::new().unwrap();
834
835            append_two_rows(num_columns, file_path.path(), &col1, &col2);
836
837            simulate_interrupted_prune(num_columns, file_path.path(), num_rows, missing_offsets);
838
839            let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
840            assert_eq!(nippy.rows, expected_rows);
841        }
842    }
843
844    fn test_append_consistency_partial_commit(
845        file_path: &Path,
846        col1: &[Vec<u8>],
847        col2: &[Vec<u8>],
848    ) {
849        let nippy = NippyJar::load_without_header(file_path).unwrap();
850
851        // Set the baseline that should be unwinded to
852        let initial_rows = nippy.rows;
853        let initial_data_size =
854            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
855        let initial_offset_size =
856            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize;
857        assert!(initial_data_size > 0);
858        assert!(initial_offset_size > 0);
859
860        // Appends a third row
861        let mut writer = NippyJarWriter::new(nippy).unwrap();
862        writer.append_column(Some(Ok(&col1[2]))).unwrap();
863        writer.append_column(Some(Ok(&col2[2]))).unwrap();
864
865        // Makes sure it doesn't write the last one offset (which is the expected file data size)
866        let _ = writer.offsets_mut().pop();
867
868        // `commit_offsets` is not a pub function. we call it here to simulate the shutdown before
869        // it can flush nippy.rows (config) to disk.
870        writer.commit_offsets().unwrap();
871
872        // Simulate an unexpected shutdown of the writer, before it can finish commit()
873        drop(writer);
874
875        let nippy = NippyJar::load_without_header(file_path).unwrap();
876        assert_eq!(initial_rows, nippy.rows);
877
878        // Data was written successfully
879        let new_data_size =
880            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
881        assert_eq!(new_data_size, initial_data_size + col1[2].len() + col2[2].len());
882
883        // It should be + 16 (two columns were added), but there's a missing one (the one we pop)
884        assert_eq!(
885            initial_offset_size + 8,
886            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize
887        );
888
889        // Writer will execute a consistency check and verify first that the offset list on disk
890        // doesn't match the nippy.rows, and prune it. Then, it will prune the data file
891        // accordingly as well.
892        let writer = NippyJarWriter::new(nippy).unwrap();
893        assert_eq!(initial_rows, writer.rows());
894        assert_eq!(
895            initial_offset_size,
896            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len() as usize
897        );
898        assert_eq!(
899            initial_data_size,
900            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize
901        );
902    }
903
904    fn test_append_consistency_no_commit(file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
905        let nippy = NippyJar::load_without_header(file_path).unwrap();
906
907        // Set the baseline that should be unwinded to
908        let initial_rows = nippy.rows;
909        let initial_data_size =
910            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
911        let initial_offset_size =
912            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize;
913        assert!(initial_data_size > 0);
914        assert!(initial_offset_size > 0);
915
916        // Appends a third row, so we have an offset list in memory, which is not flushed to disk,
917        // while the data has been.
918        let mut writer = NippyJarWriter::new(nippy).unwrap();
919        writer.append_column(Some(Ok(&col1[2]))).unwrap();
920        writer.append_column(Some(Ok(&col2[2]))).unwrap();
921
922        // Simulate an unexpected shutdown of the writer, before it can call commit()
923        drop(writer);
924
925        let nippy = NippyJar::load_without_header(file_path).unwrap();
926        assert_eq!(initial_rows, nippy.rows);
927
928        // Data was written successfully
929        let new_data_size =
930            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
931        assert_eq!(new_data_size, initial_data_size + col1[2].len() + col2[2].len());
932
933        // Since offsets only get written on commit(), this remains the same
934        assert_eq!(
935            initial_offset_size,
936            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize
937        );
938
939        // Writer will execute a consistency check and verify that the data file has more data than
940        // it should, and resets it to the last offset of the list (on disk here)
941        let writer = NippyJarWriter::new(nippy).unwrap();
942        assert_eq!(initial_rows, writer.rows());
943        assert_eq!(
944            initial_data_size,
945            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize
946        );
947    }
948
949    fn append_two_rows(num_columns: usize, file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
950        // Create and add 1 row
951        {
952            let nippy = NippyJar::new_without_header(num_columns, file_path);
953            nippy.freeze_config().unwrap();
954            assert_eq!(nippy.max_row_size, 0);
955            assert_eq!(nippy.rows, 0);
956
957            let mut writer = NippyJarWriter::new(nippy).unwrap();
958            assert_eq!(writer.column(), 0);
959
960            writer.append_column(Some(Ok(&col1[0]))).unwrap();
961            assert_eq!(writer.column(), 1);
962            assert!(writer.is_dirty());
963
964            writer.append_column(Some(Ok(&col2[0]))).unwrap();
965            assert!(writer.is_dirty());
966
967            // Adding last column of a row resets writer and updates jar config
968            assert_eq!(writer.column(), 0);
969
970            // One offset per column + 1 offset at the end representing the expected file data size
971            assert_eq!(writer.offsets().len(), 3);
972            let expected_data_file_size = *writer.offsets().last().unwrap();
973            writer.commit().unwrap();
974            assert!(!writer.is_dirty());
975
976            assert_eq!(writer.max_row_size(), col1[0].len() + col2[0].len());
977            assert_eq!(writer.rows(), 1);
978            assert_eq!(
979                File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
980                1 + num_columns as u64 * 8 + 8
981            );
982            assert_eq!(
983                File::open(writer.data_path()).unwrap().metadata().unwrap().len(),
984                expected_data_file_size
985            );
986        }
987
988        // Load and add 1 row
989        {
990            let nippy = NippyJar::load_without_header(file_path).unwrap();
991            // Check if it was committed successfully
992            assert_eq!(nippy.max_row_size, col1[0].len() + col2[0].len());
993            assert_eq!(nippy.rows, 1);
994
995            let mut writer = NippyJarWriter::new(nippy).unwrap();
996            assert_eq!(writer.column(), 0);
997
998            writer.append_column(Some(Ok(&col1[1]))).unwrap();
999            assert_eq!(writer.column(), 1);
1000
1001            writer.append_column(Some(Ok(&col2[1]))).unwrap();
1002
1003            // Adding last column of a row resets writer and updates jar config
1004            assert_eq!(writer.column(), 0);
1005
1006            // One offset per column + 1 offset at the end representing the expected file data size
1007            assert_eq!(writer.offsets().len(), 3);
1008            let expected_data_file_size = *writer.offsets().last().unwrap();
1009            writer.commit().unwrap();
1010
1011            assert_eq!(writer.max_row_size(), col1[0].len() + col2[0].len());
1012            assert_eq!(writer.rows(), 2);
1013            assert_eq!(
1014                File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1015                1 + writer.rows() as u64 * num_columns as u64 * 8 + 8
1016            );
1017            assert_eq!(
1018                File::open(writer.data_path()).unwrap().metadata().unwrap().len(),
1019                expected_data_file_size
1020            );
1021        }
1022    }
1023
1024    fn prune_rows(num_columns: usize, file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1025        let nippy = NippyJar::load_without_header(file_path).unwrap();
1026        let mut writer = NippyJarWriter::new(nippy).unwrap();
1027
1028        // Appends a third row, so we have an offset list in memory, which is not flushed to disk
1029        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1030        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1031        assert!(writer.is_dirty());
1032
1033        // This should prune from the on-memory offset list and ondisk offset list
1034        writer.prune_rows(2).unwrap();
1035        assert_eq!(writer.rows(), 1);
1036
1037        assert_eq!(
1038            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1039            1 + writer.rows() as u64 * num_columns as u64 * 8 + 8
1040        );
1041
1042        let expected_data_size = col1[0].len() + col2[0].len();
1043        assert_eq!(
1044            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize,
1045            expected_data_size
1046        );
1047
1048        let nippy = NippyJar::load_without_header(file_path).unwrap();
1049        {
1050            let data_reader = nippy.open_data_reader().unwrap();
1051            // there are only two valid offsets. so index 2 actually represents the expected file
1052            // data size.
1053            assert_eq!(data_reader.offset(2).unwrap(), expected_data_size as u64);
1054        }
1055
1056        // This should prune from the ondisk offset list and clear the jar.
1057        let mut writer = NippyJarWriter::new(nippy).unwrap();
1058        writer.prune_rows(1).unwrap();
1059        assert!(writer.is_dirty());
1060
1061        assert_eq!(writer.rows(), 0);
1062        assert_eq!(writer.max_row_size(), 0);
1063        assert_eq!(File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize, 0);
1064        // Offset size byte (1) + final offset (8) = 9 bytes
1065        assert_eq!(
1066            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len() as usize,
1067            9
1068        );
1069        writer.commit().unwrap();
1070        assert!(!writer.is_dirty());
1071    }
1072
1073    fn simulate_interrupted_prune(
1074        num_columns: usize,
1075        file_path: &Path,
1076        num_rows: u64,
1077        missing_offsets: u64,
1078    ) {
1079        let nippy = NippyJar::load_without_header(file_path).unwrap();
1080        let reader = nippy.open_data_reader().unwrap();
1081        let offsets_file =
1082            OpenOptions::new().read(true).write(true).open(nippy.offsets_path()).unwrap();
1083        let offsets_len = 1 + num_rows * num_columns as u64 * 8 + 8;
1084        assert_eq!(offsets_len, offsets_file.metadata().unwrap().len());
1085
1086        let data_file = OpenOptions::new().read(true).write(true).open(nippy.data_path()).unwrap();
1087        let data_len = reader.reverse_offset(0).unwrap();
1088        assert_eq!(data_len, data_file.metadata().unwrap().len());
1089
1090        // each data column is 32 bytes long
1091        // by deleting from the data file, the `consistency_check` will go through both branches:
1092        //      when the offset list wasn't updated after clearing the data (data_len > last
1093        // offset).      fixing above, will lead to offset count not match the rows (*
1094        // columns) of the configuration file
1095        data_file.set_len(data_len - 32 * missing_offsets).unwrap();
1096
1097        // runs the consistency check.
1098        let _ = NippyJarWriter::new(nippy).unwrap();
1099    }
1100}