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::{
453        fs::OpenOptions,
454        io::{Read, Seek, SeekFrom},
455    };
456
457    type ColumnResults<T> = Vec<ColumnResult<T>>;
458    type ColumnValues = Vec<Vec<u8>>;
459
460    fn test_data(seed: Option<u64>) -> (ColumnValues, ColumnValues) {
461        let value_length = 32;
462        let num_rows = 100;
463
464        let mut vec: Vec<u8> = vec![0; value_length];
465        let mut rng = seed.map(SmallRng::seed_from_u64).unwrap_or_else(SmallRng::from_os_rng);
466
467        let mut entry_gen = || {
468            (0..num_rows)
469                .map(|_| {
470                    rng.fill_bytes(&mut vec[..]);
471                    vec.clone()
472                })
473                .collect()
474        };
475
476        (entry_gen(), entry_gen())
477    }
478
479    fn clone_with_result(col: &ColumnValues) -> ColumnResults<Vec<u8>> {
480        col.iter().map(|v| Ok(v.clone())).collect()
481    }
482
483    #[test]
484    fn test_config_serialization() {
485        let file = tempfile::NamedTempFile::new().unwrap();
486        let jar = NippyJar::new_without_header(23, file.path()).with_lz4();
487        jar.freeze_config().unwrap();
488
489        let mut config_file = OpenOptions::new().read(true).open(jar.config_path()).unwrap();
490        let config_file_len = config_file.metadata().unwrap().len();
491        assert_eq!(config_file_len, 37);
492
493        let mut buf = Vec::with_capacity(config_file_len as usize);
494        config_file.read_to_end(&mut buf).unwrap();
495
496        assert_eq!(
497            vec![
498                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,
499                0, 0, 0, 0, 0, 0, 0, 0, 0, 0
500            ],
501            buf
502        );
503
504        let mut read_jar = bincode::deserialize_from::<_, NippyJar>(&buf[..]).unwrap();
505        // Path is not ser/de
506        read_jar.path = file.path().to_path_buf();
507        assert_eq!(jar, read_jar);
508    }
509
510    #[test]
511    fn test_zstd_with_dictionaries() {
512        let (col1, col2) = test_data(None);
513        let num_rows = col1.len() as u64;
514        let num_columns = 2;
515        let file_path = tempfile::NamedTempFile::new().unwrap();
516
517        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
518        assert!(nippy.compressor().is_none());
519
520        let mut nippy =
521            NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(true, 5000);
522        assert!(nippy.compressor().is_some());
523
524        if let Some(Compressors::Zstd(zstd)) = &mut nippy.compressor_mut() {
525            assert!(matches!(zstd.compressors(), Err(NippyJarError::CompressorNotReady)));
526
527            // Make sure the number of column iterators match the initial set up ones.
528            assert!(matches!(
529                zstd.prepare_compression(vec![col1.clone(), col2.clone(), col2.clone()]),
530                Err(NippyJarError::ColumnLenMismatch(columns, 3)) if columns == num_columns
531            ));
532        }
533
534        // If ZSTD is enabled, do not write to the file unless the column dictionaries have been
535        // calculated.
536        assert!(matches!(
537            nippy.freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows),
538            Err(NippyJarError::CompressorNotReady)
539        ));
540
541        let mut nippy =
542            NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(true, 5000);
543        assert!(nippy.compressor().is_some());
544
545        nippy.prepare_compression(vec![col1.clone(), col2.clone()]).unwrap();
546
547        if let Some(Compressors::Zstd(zstd)) = &nippy.compressor() {
548            assert!(matches!(
549                (&zstd.state, zstd.dictionaries.as_ref().map(|dict| dict.len())),
550                (compression::ZstdState::Ready, Some(columns)) if columns == num_columns
551            ));
552        }
553
554        let nippy = nippy
555            .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
556            .unwrap();
557
558        let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
559        assert_eq!(nippy.version, loaded_nippy.version);
560        assert_eq!(nippy.columns, loaded_nippy.columns);
561        assert_eq!(nippy.filter, loaded_nippy.filter);
562        assert_eq!(nippy.phf, loaded_nippy.phf);
563        assert_eq!(nippy.max_row_size, loaded_nippy.max_row_size);
564        assert_eq!(nippy.path, loaded_nippy.path);
565
566        if let Some(Compressors::Zstd(zstd)) = loaded_nippy.compressor() {
567            assert!(zstd.use_dict);
568            let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
569
570            // Iterate over compressed values and compare
571            let mut row_index = 0usize;
572            while let Some(row) = cursor.next_row().unwrap() {
573                assert_eq!(
574                    (row[0], row[1]),
575                    (col1[row_index].as_slice(), col2[row_index].as_slice())
576                );
577                row_index += 1;
578            }
579        } else {
580            panic!("Expected Zstd compressor")
581        }
582    }
583
584    #[test]
585    fn test_lz4() {
586        let (col1, col2) = test_data(None);
587        let num_rows = col1.len() as u64;
588        let num_columns = 2;
589        let file_path = tempfile::NamedTempFile::new().unwrap();
590
591        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
592        assert!(nippy.compressor().is_none());
593
594        let nippy = NippyJar::new_without_header(num_columns, file_path.path()).with_lz4();
595        assert!(nippy.compressor().is_some());
596
597        let nippy = nippy
598            .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
599            .unwrap();
600
601        let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
602        assert_eq!(nippy, loaded_nippy);
603
604        if let Some(Compressors::Lz4(_)) = loaded_nippy.compressor() {
605            let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
606
607            // Iterate over compressed values and compare
608            let mut row_index = 0usize;
609            while let Some(row) = cursor.next_row().unwrap() {
610                assert_eq!(
611                    (row[0], row[1]),
612                    (col1[row_index].as_slice(), col2[row_index].as_slice())
613                );
614                row_index += 1;
615            }
616        } else {
617            panic!("Expected Lz4 compressor")
618        }
619    }
620
621    #[test]
622    fn test_zstd_no_dictionaries() {
623        let (col1, col2) = test_data(None);
624        let num_rows = col1.len() as u64;
625        let num_columns = 2;
626        let file_path = tempfile::NamedTempFile::new().unwrap();
627
628        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
629        assert!(nippy.compressor().is_none());
630
631        let nippy =
632            NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(false, 5000);
633        assert!(nippy.compressor().is_some());
634
635        let nippy = nippy
636            .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
637            .unwrap();
638
639        let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
640        assert_eq!(nippy, loaded_nippy);
641
642        if let Some(Compressors::Zstd(zstd)) = loaded_nippy.compressor() {
643            assert!(!zstd.use_dict);
644
645            let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
646
647            // Iterate over compressed values and compare
648            let mut row_index = 0usize;
649            while let Some(row) = cursor.next_row().unwrap() {
650                assert_eq!(
651                    (row[0], row[1]),
652                    (col1[row_index].as_slice(), col2[row_index].as_slice())
653                );
654                row_index += 1;
655            }
656        } else {
657            panic!("Expected Zstd compressor")
658        }
659    }
660
661    /// Tests `NippyJar` with everything enabled.
662    #[test]
663    fn test_full_nippy_jar() {
664        let (col1, col2) = test_data(None);
665        let num_rows = col1.len() as u64;
666        let num_columns = 2;
667        let file_path = tempfile::NamedTempFile::new().unwrap();
668        let data = vec![col1.clone(), col2.clone()];
669
670        let block_start = 500;
671
672        #[derive(Serialize, Deserialize, Debug)]
673        struct BlockJarHeader {
674            block_start: usize,
675        }
676
677        // Create file
678        {
679            let mut nippy =
680                NippyJar::new(num_columns, file_path.path(), BlockJarHeader { block_start })
681                    .with_zstd(true, 5000);
682
683            nippy.prepare_compression(data.clone()).unwrap();
684            nippy
685                .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
686                .unwrap();
687        }
688
689        // Read file
690        {
691            let loaded_nippy = NippyJar::<BlockJarHeader>::load(file_path.path()).unwrap();
692
693            assert!(loaded_nippy.compressor().is_some());
694            assert_eq!(loaded_nippy.user_header().block_start, block_start);
695
696            if let Some(Compressors::Zstd(_zstd)) = loaded_nippy.compressor() {
697                let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
698
699                // Iterate over compressed values and compare
700                let mut row_num = 0usize;
701                while let Some(row) = cursor.next_row().unwrap() {
702                    assert_eq!(
703                        (row[0], row[1]),
704                        (data[0][row_num].as_slice(), data[1][row_num].as_slice())
705                    );
706                    row_num += 1;
707                }
708
709                // Shuffled for chaos.
710                let mut data = col1.iter().zip(col2.iter()).enumerate().collect::<Vec<_>>();
711                data.shuffle(&mut rand::rng());
712
713                for (row_num, (v0, v1)) in data {
714                    // Simulates `by_number` queries
715                    let row_by_num = cursor.row_by_number(row_num).unwrap().unwrap();
716                    assert_eq!((&row_by_num[0].to_vec(), &row_by_num[1].to_vec()), (v0, v1));
717                }
718            }
719        }
720    }
721
722    #[test]
723    fn test_selectable_column_values() {
724        let (col1, col2) = test_data(None);
725        let num_rows = col1.len() as u64;
726        let num_columns = 2;
727        let file_path = tempfile::NamedTempFile::new().unwrap();
728        let data = vec![col1.clone(), col2.clone()];
729
730        // Create file
731        {
732            let mut nippy =
733                NippyJar::new_without_header(num_columns, file_path.path()).with_zstd(true, 5000);
734            nippy.prepare_compression(data).unwrap();
735            nippy
736                .freeze(vec![clone_with_result(&col1), clone_with_result(&col2)], num_rows)
737                .unwrap();
738        }
739
740        // Read file
741        {
742            let loaded_nippy = NippyJar::load_without_header(file_path.path()).unwrap();
743
744            if let Some(Compressors::Zstd(_zstd)) = loaded_nippy.compressor() {
745                let mut cursor = NippyJarCursor::new(&loaded_nippy).unwrap();
746
747                // Shuffled for chaos.
748                let mut data = col1.iter().zip(col2.iter()).enumerate().collect::<Vec<_>>();
749                data.shuffle(&mut rand::rng());
750
751                // Imagine `Blocks` static file has two columns: `Block | StoredWithdrawals`
752                const BLOCKS_FULL_MASK: usize = 0b11;
753
754                // Read both columns
755                for (row_num, (v0, v1)) in &data {
756                    // Simulates `by_number` queries
757                    let row_by_num = cursor
758                        .row_by_number_with_cols(*row_num, BLOCKS_FULL_MASK)
759                        .unwrap()
760                        .unwrap();
761                    assert_eq!((&row_by_num[0].to_vec(), &row_by_num[1].to_vec()), (*v0, *v1));
762                }
763
764                // Read first column only: `Block`
765                const BLOCKS_BLOCK_MASK: usize = 0b01;
766                for (row_num, (v0, _)) in &data {
767                    // Simulates `by_number` queries
768                    let row_by_num = cursor
769                        .row_by_number_with_cols(*row_num, BLOCKS_BLOCK_MASK)
770                        .unwrap()
771                        .unwrap();
772                    assert_eq!(row_by_num.len(), 1);
773                    assert_eq!(&row_by_num[0].to_vec(), *v0);
774                }
775
776                // Read second column only: `Block`
777                const BLOCKS_WITHDRAWAL_MASK: usize = 0b10;
778                for (row_num, (_, v1)) in &data {
779                    // Simulates `by_number` queries
780                    let row_by_num = cursor
781                        .row_by_number_with_cols(*row_num, BLOCKS_WITHDRAWAL_MASK)
782                        .unwrap()
783                        .unwrap();
784                    assert_eq!(row_by_num.len(), 1);
785                    assert_eq!(&row_by_num[0].to_vec(), *v1);
786                }
787
788                // Read nothing
789                const BLOCKS_EMPTY_MASK: usize = 0b00;
790                for (row_num, _) in &data {
791                    // Simulates `by_number` queries
792                    assert!(cursor
793                        .row_by_number_with_cols(*row_num, BLOCKS_EMPTY_MASK)
794                        .unwrap()
795                        .unwrap()
796                        .is_empty());
797                }
798            }
799        }
800    }
801
802    #[test]
803    fn test_writer() {
804        let (col1, col2) = test_data(None);
805        let num_columns = 2;
806        let file_path = tempfile::NamedTempFile::new().unwrap();
807
808        append_two_rows(num_columns, file_path.path(), &col1, &col2);
809
810        // Appends a third row and prunes two rows, to make sure we prune from memory and disk
811        // offset list
812        prune_rows(num_columns, file_path.path(), &col1, &col2);
813
814        // Should be able to append new rows
815        append_two_rows(num_columns, file_path.path(), &col1, &col2);
816
817        // Simulate an unexpected shutdown before there's a chance to commit, and see that it
818        // unwinds successfully
819        test_append_consistency_no_commit(file_path.path(), &col1, &col2);
820
821        // Simulate an unexpected shutdown during commit, and see that it unwinds successfully
822        test_append_consistency_partial_commit(file_path.path(), &col1, &col2);
823    }
824
825    #[test]
826    fn test_pruner() {
827        let (col1, col2) = test_data(None);
828        let num_columns = 2;
829        let num_rows = 2;
830
831        // (missing_offsets, expected number of rows)
832        // If a row wasn't fully pruned, then it should clear it up as well
833        let missing_offsets_scenarios = [(1, 1), (2, 1), (3, 0)];
834
835        for (missing_offsets, expected_rows) in missing_offsets_scenarios {
836            let file_path = tempfile::NamedTempFile::new().unwrap();
837
838            append_two_rows(num_columns, file_path.path(), &col1, &col2);
839
840            simulate_interrupted_prune(num_columns, file_path.path(), num_rows, missing_offsets);
841
842            let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
843            assert_eq!(nippy.rows, expected_rows);
844        }
845    }
846
847    #[test]
848    fn test_read_last_row_while_appending() {
849        let (col1, col2) = test_data(None);
850        let num_columns = 2;
851        let file_path = tempfile::NamedTempFile::new().unwrap();
852
853        append_two_rows(num_columns, file_path.path(), &col1, &col2);
854
855        // Appends a third row to the data file, without committing its offsets and the row count.
856        let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
857        let mut writer = NippyJarWriter::new(nippy).unwrap();
858        writer.append_column(Some(Ok(&col1[2]))).unwrap();
859        writer.append_column(Some(Ok(&col2[2]))).unwrap();
860        writer.data_file().flush().unwrap();
861
862        // A jar loaded meanwhile reads the last committed row as it was written.
863        let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
864        assert_eq!(nippy.rows, 2);
865        let mut cursor = NippyJarCursor::new(&nippy).unwrap();
866        assert_eq!(
867            cursor.row_by_number(1).unwrap().unwrap().as_slice(),
868            [col1[1].as_slice(), col2[1].as_slice()]
869        );
870        assert!(cursor.row_by_number(2).unwrap().is_none());
871    }
872
873    #[test]
874    fn test_read_last_row_with_invalid_final_offset() {
875        let (col1, col2) = test_data(None);
876        let num_columns = 2;
877
878        // Overwrites the final offset of a committed jar, then reads its last row.
879        let assert_inconsistent = |final_offset: fn(u64) -> u64| {
880            let file_path = tempfile::NamedTempFile::new().unwrap();
881            append_two_rows(num_columns, file_path.path(), &col1, &col2);
882
883            let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
884            let data_len = File::open(nippy.data_path()).unwrap().metadata().unwrap().len();
885            let mut offsets =
886                OpenOptions::new().read(true).write(true).open(nippy.offsets_path()).unwrap();
887            offsets.seek(SeekFrom::End(-8)).unwrap();
888            offsets.write_all(&final_offset(data_len).to_le_bytes()).unwrap();
889            offsets.sync_all().unwrap();
890
891            let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
892            let mut cursor = NippyJarCursor::new(&nippy).unwrap();
893            assert!(matches!(cursor.row_by_number(1), Err(NippyJarError::InconsistentState)));
894        };
895
896        // Before the start of the last value.
897        assert_inconsistent(|_| 0);
898        // Past the end of the data file.
899        assert_inconsistent(|data_len| data_len + 1);
900    }
901
902    #[test]
903    fn test_prune_partially_uncommitted_rows() {
904        let (col1, col2) = test_data(None);
905        let num_columns = 2;
906        let file_path = tempfile::NamedTempFile::new().unwrap();
907
908        // Two committed rows.
909        append_two_rows(num_columns, file_path.path(), &col1, &col2);
910
911        let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
912        let mut writer = NippyJarWriter::new(nippy).unwrap();
913
914        // Two uncommitted rows, of which only one is pruned again.
915        append_row(&mut writer, &col1, &col2, 2);
916        append_row(&mut writer, &col1, &col2, 3);
917        writer.prune_rows(1).unwrap();
918        assert_eq!(writer.rows(), 3);
919
920        // The config on disk must never claim rows whose offsets are not on disk.
921        let expected_offsets_len = 1 + 3 * num_columns as u64 * 8 + 8;
922        assert_eq!(
923            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
924            expected_offsets_len
925        );
926        let expected_data_len = col1[..3].iter().chain(&col2[..3]).map(Vec::len).sum::<usize>();
927        assert_eq!(
928            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize,
929            expected_data_len
930        );
931        assert_jar_rows(file_path.path(), &col1, &col2, 3);
932
933        // Committing must not change anything, and the writer remains usable.
934        writer.commit().unwrap();
935        assert_eq!(
936            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
937            expected_offsets_len
938        );
939        assert_jar_rows(file_path.path(), &col1, &col2, 3);
940
941        append_row(&mut writer, &col1, &col2, 3);
942        writer.commit().unwrap();
943        assert_eq!(
944            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
945            1 + 4 * num_columns as u64 * 8 + 8
946        );
947        assert_jar_rows(file_path.path(), &col1, &col2, 4);
948    }
949
950    #[test]
951    fn test_prune_uncommitted_and_committed_rows() {
952        let (col1, col2) = test_data(None);
953        let num_columns = 2;
954        let file_path = tempfile::NamedTempFile::new().unwrap();
955
956        // Two committed rows.
957        append_two_rows(num_columns, file_path.path(), &col1, &col2);
958
959        let nippy = NippyJar::load_without_header(file_path.path()).unwrap();
960        let mut writer = NippyJarWriter::new(nippy).unwrap();
961
962        // One uncommitted row, which is pruned together with one committed row.
963        append_row(&mut writer, &col1, &col2, 2);
964        writer.prune_rows(2).unwrap();
965        assert_eq!(writer.rows(), 1);
966
967        // Committing must not write the stale in-memory offset of the pruned rows.
968        writer.commit().unwrap();
969        assert_eq!(
970            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
971            1 + num_columns as u64 * 8 + 8
972        );
973        assert_eq!(
974            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize,
975            col1[0].len() + col2[0].len()
976        );
977        assert_jar_rows(file_path.path(), &col1, &col2, 1);
978
979        // Rows appended through the same writer afterwards read back correctly.
980        append_row(&mut writer, &col1, &col2, 1);
981        writer.commit().unwrap();
982        assert_eq!(
983            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
984            1 + 2 * num_columns as u64 * 8 + 8
985        );
986        assert_jar_rows(file_path.path(), &col1, &col2, 2);
987    }
988
989    fn test_append_consistency_partial_commit(
990        file_path: &Path,
991        col1: &[Vec<u8>],
992        col2: &[Vec<u8>],
993    ) {
994        let nippy = NippyJar::load_without_header(file_path).unwrap();
995
996        // Set the baseline that should be unwinded to
997        let initial_rows = nippy.rows;
998        let initial_data_size =
999            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1000        let initial_offset_size =
1001            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize;
1002        assert!(initial_data_size > 0);
1003        assert!(initial_offset_size > 0);
1004
1005        // Appends a third row
1006        let mut writer = NippyJarWriter::new(nippy).unwrap();
1007        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1008        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1009
1010        // Makes sure it doesn't write the last one offset (which is the expected file data size)
1011        let _ = writer.offsets_mut().pop();
1012
1013        // `commit_offsets` is not a pub function. we call it here to simulate the shutdown before
1014        // it can flush nippy.rows (config) to disk.
1015        writer.commit_offsets().unwrap();
1016
1017        // Simulate an unexpected shutdown of the writer, before it can finish commit()
1018        drop(writer);
1019
1020        let nippy = NippyJar::load_without_header(file_path).unwrap();
1021        assert_eq!(initial_rows, nippy.rows);
1022
1023        // Data was written successfully
1024        let new_data_size =
1025            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1026        assert_eq!(new_data_size, initial_data_size + col1[2].len() + col2[2].len());
1027
1028        // It should be + 16 (two columns were added), but there's a missing one (the one we pop)
1029        assert_eq!(
1030            initial_offset_size + 8,
1031            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize
1032        );
1033
1034        // Writer will execute a consistency check and verify first that the offset list on disk
1035        // doesn't match the nippy.rows, and prune it. Then, it will prune the data file
1036        // accordingly as well.
1037        let writer = NippyJarWriter::new(nippy).unwrap();
1038        assert_eq!(initial_rows, writer.rows());
1039        assert_eq!(
1040            initial_offset_size,
1041            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len() as usize
1042        );
1043        assert_eq!(
1044            initial_data_size,
1045            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize
1046        );
1047    }
1048
1049    fn test_append_consistency_no_commit(file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1050        let nippy = NippyJar::load_without_header(file_path).unwrap();
1051
1052        // Set the baseline that should be unwinded to
1053        let initial_rows = nippy.rows;
1054        let initial_data_size =
1055            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1056        let initial_offset_size =
1057            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize;
1058        assert!(initial_data_size > 0);
1059        assert!(initial_offset_size > 0);
1060
1061        // Appends a third row, so we have an offset list in memory, which is not flushed to disk,
1062        // while the data has been.
1063        let mut writer = NippyJarWriter::new(nippy).unwrap();
1064        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1065        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1066
1067        // Simulate an unexpected shutdown of the writer, before it can call commit()
1068        drop(writer);
1069
1070        let nippy = NippyJar::load_without_header(file_path).unwrap();
1071        assert_eq!(initial_rows, nippy.rows);
1072
1073        // Data was written successfully
1074        let new_data_size =
1075            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1076        assert_eq!(new_data_size, initial_data_size + col1[2].len() + col2[2].len());
1077
1078        // Since offsets only get written on commit(), this remains the same
1079        assert_eq!(
1080            initial_offset_size,
1081            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize
1082        );
1083
1084        // Writer will execute a consistency check and verify that the data file has more data than
1085        // it should, and resets it to the last offset of the list (on disk here)
1086        let writer = NippyJarWriter::new(nippy).unwrap();
1087        assert_eq!(initial_rows, writer.rows());
1088        assert_eq!(
1089            initial_data_size,
1090            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize
1091        );
1092    }
1093
1094    fn append_two_rows(num_columns: usize, file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1095        // Create and add 1 row
1096        {
1097            let nippy = NippyJar::new_without_header(num_columns, file_path);
1098            nippy.freeze_config().unwrap();
1099            assert_eq!(nippy.max_row_size, 0);
1100            assert_eq!(nippy.rows, 0);
1101
1102            let mut writer = NippyJarWriter::new(nippy).unwrap();
1103            assert_eq!(writer.column(), 0);
1104
1105            writer.append_column(Some(Ok(&col1[0]))).unwrap();
1106            assert_eq!(writer.column(), 1);
1107            assert!(writer.is_dirty());
1108
1109            writer.append_column(Some(Ok(&col2[0]))).unwrap();
1110            assert!(writer.is_dirty());
1111
1112            // Adding last column of a row resets writer and updates jar config
1113            assert_eq!(writer.column(), 0);
1114
1115            // One offset per column + 1 offset at the end representing the expected file data size
1116            assert_eq!(writer.offsets().len(), 3);
1117            let expected_data_file_size = *writer.offsets().last().unwrap();
1118            writer.commit().unwrap();
1119            assert!(!writer.is_dirty());
1120
1121            assert_eq!(writer.max_row_size(), col1[0].len() + col2[0].len());
1122            assert_eq!(writer.rows(), 1);
1123            assert_eq!(
1124                File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1125                1 + num_columns as u64 * 8 + 8
1126            );
1127            assert_eq!(
1128                File::open(writer.data_path()).unwrap().metadata().unwrap().len(),
1129                expected_data_file_size
1130            );
1131        }
1132
1133        // Load and add 1 row
1134        {
1135            let nippy = NippyJar::load_without_header(file_path).unwrap();
1136            // Check if it was committed successfully
1137            assert_eq!(nippy.max_row_size, col1[0].len() + col2[0].len());
1138            assert_eq!(nippy.rows, 1);
1139
1140            let mut writer = NippyJarWriter::new(nippy).unwrap();
1141            assert_eq!(writer.column(), 0);
1142
1143            writer.append_column(Some(Ok(&col1[1]))).unwrap();
1144            assert_eq!(writer.column(), 1);
1145
1146            writer.append_column(Some(Ok(&col2[1]))).unwrap();
1147
1148            // Adding last column of a row resets writer and updates jar config
1149            assert_eq!(writer.column(), 0);
1150
1151            // One offset per column + 1 offset at the end representing the expected file data size
1152            assert_eq!(writer.offsets().len(), 3);
1153            let expected_data_file_size = *writer.offsets().last().unwrap();
1154            writer.commit().unwrap();
1155
1156            assert_eq!(writer.max_row_size(), col1[0].len() + col2[0].len());
1157            assert_eq!(writer.rows(), 2);
1158            assert_eq!(
1159                File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1160                1 + writer.rows() as u64 * num_columns as u64 * 8 + 8
1161            );
1162            assert_eq!(
1163                File::open(writer.data_path()).unwrap().metadata().unwrap().len(),
1164                expected_data_file_size
1165            );
1166        }
1167    }
1168
1169    fn prune_rows(num_columns: usize, file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1170        let nippy = NippyJar::load_without_header(file_path).unwrap();
1171        let mut writer = NippyJarWriter::new(nippy).unwrap();
1172
1173        // Appends a third row, so we have an offset list in memory, which is not flushed to disk
1174        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1175        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1176        assert!(writer.is_dirty());
1177
1178        // This should prune from the on-memory offset list and ondisk offset list
1179        writer.prune_rows(2).unwrap();
1180        assert_eq!(writer.rows(), 1);
1181
1182        assert_eq!(
1183            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1184            1 + writer.rows() as u64 * num_columns as u64 * 8 + 8
1185        );
1186
1187        let expected_data_size = col1[0].len() + col2[0].len();
1188        assert_eq!(
1189            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize,
1190            expected_data_size
1191        );
1192
1193        let nippy = NippyJar::load_without_header(file_path).unwrap();
1194        {
1195            let data_reader = nippy.open_data_reader().unwrap();
1196            // there are only two valid offsets. so index 2 actually represents the expected file
1197            // data size.
1198            assert_eq!(data_reader.offset(2).unwrap(), expected_data_size as u64);
1199        }
1200
1201        // This should prune from the ondisk offset list and clear the jar.
1202        let mut writer = NippyJarWriter::new(nippy).unwrap();
1203        writer.prune_rows(1).unwrap();
1204        assert!(writer.is_dirty());
1205
1206        assert_eq!(writer.rows(), 0);
1207        assert_eq!(writer.max_row_size(), 0);
1208        assert_eq!(File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize, 0);
1209        // Offset size byte (1) + final offset (8) = 9 bytes
1210        assert_eq!(
1211            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len() as usize,
1212            9
1213        );
1214        writer.commit().unwrap();
1215        assert!(!writer.is_dirty());
1216    }
1217
1218    fn simulate_interrupted_prune(
1219        num_columns: usize,
1220        file_path: &Path,
1221        num_rows: u64,
1222        missing_offsets: u64,
1223    ) {
1224        let nippy = NippyJar::load_without_header(file_path).unwrap();
1225        let reader = nippy.open_data_reader().unwrap();
1226        let offsets_file =
1227            OpenOptions::new().read(true).write(true).open(nippy.offsets_path()).unwrap();
1228        let offsets_len = 1 + num_rows * num_columns as u64 * 8 + 8;
1229        assert_eq!(offsets_len, offsets_file.metadata().unwrap().len());
1230
1231        let data_file = OpenOptions::new().read(true).write(true).open(nippy.data_path()).unwrap();
1232        let data_len = reader.reverse_offset(0).unwrap();
1233        assert_eq!(data_len, data_file.metadata().unwrap().len());
1234
1235        // each data column is 32 bytes long
1236        // by deleting from the data file, the `consistency_check` will go through both branches:
1237        //      when the offset list wasn't updated after clearing the data (data_len > last
1238        // offset).      fixing above, will lead to offset count not match the rows (*
1239        // columns) of the configuration file
1240        data_file.set_len(data_len - 32 * missing_offsets).unwrap();
1241
1242        // runs the consistency check.
1243        let _ = NippyJarWriter::new(nippy).unwrap();
1244    }
1245
1246    fn append_row(writer: &mut NippyJarWriter, col1: &[Vec<u8>], col2: &[Vec<u8>], row: usize) {
1247        writer.append_column(Some(Ok(&col1[row]))).unwrap();
1248        writer.append_column(Some(Ok(&col2[row]))).unwrap();
1249    }
1250
1251    /// Asserts that the jar on disk is consistent, claims exactly `rows` rows, and that every
1252    /// one of them reads back as the corresponding entry of `col1` and `col2`.
1253    fn assert_jar_rows(file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>], rows: usize) {
1254        let nippy = NippyJar::load_without_header(file_path).unwrap();
1255        assert_eq!(nippy.rows, rows);
1256        NippyJarChecker::new(NippyJar::load_without_header(file_path).unwrap())
1257            .check_consistency()
1258            .unwrap();
1259
1260        let mut cursor = NippyJarCursor::new(&nippy).unwrap();
1261        for row in 0..rows {
1262            let values = cursor.row_by_number(row).unwrap().unwrap();
1263            assert_eq!((values[0], values[1]), (col1[row].as_slice(), col2[row].as_slice()));
1264        }
1265        assert!(cursor.row_by_number(rows).unwrap().is_none());
1266    }
1267}