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    #[test]
990    fn test_create_over_leftover_offsets() {
991        let (col1, col2) = test_data(None);
992        let num_columns = 2;
993        let file_path = tempfile::NamedTempFile::new().unwrap();
994
995        // Two committed rows, of which only the offsets are left behind.
996        append_two_rows(num_columns, file_path.path(), &col1, &col2);
997        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
998        reth_fs_util::remove_file(nippy.data_path()).unwrap();
999
1000        let mut writer = NippyJarWriter::new(nippy).unwrap();
1001        append_row(&mut writer, &col1, &col2, 2);
1002        writer.commit().unwrap();
1003
1004        // All values have the same size, so stale offsets equal the fresh ones and only the length
1005        // shows them.
1006        assert_eq!(
1007            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1008            1 + num_columns as u64 * 8 + 8
1009        );
1010        assert_jar_rows(file_path.path(), &col1[2..], &col2[2..], 1);
1011    }
1012
1013    #[test]
1014    fn test_create_over_leftover_data() {
1015        let (col1, col2) = test_data(None);
1016        let num_columns = 2;
1017        let file_path = tempfile::NamedTempFile::new().unwrap();
1018
1019        // Two committed rows, of which only the data is left behind.
1020        append_two_rows(num_columns, file_path.path(), &col1, &col2);
1021        let nippy = NippyJar::new_without_header(num_columns, file_path.path());
1022        reth_fs_util::remove_file(nippy.offsets_path()).unwrap();
1023
1024        let mut writer = NippyJarWriter::new(nippy).unwrap();
1025        append_row(&mut writer, &col1, &col2, 2);
1026        writer.commit().unwrap();
1027
1028        assert_eq!(
1029            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize,
1030            col1[2].len() + col2[2].len()
1031        );
1032        assert_jar_rows(file_path.path(), &col1[2..], &col2[2..], 1);
1033    }
1034
1035    fn test_append_consistency_partial_commit(
1036        file_path: &Path,
1037        col1: &[Vec<u8>],
1038        col2: &[Vec<u8>],
1039    ) {
1040        let nippy = NippyJar::load_without_header(file_path).unwrap();
1041
1042        // Set the baseline that should be unwinded to
1043        let initial_rows = nippy.rows;
1044        let initial_data_size =
1045            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1046        let initial_offset_size =
1047            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize;
1048        assert!(initial_data_size > 0);
1049        assert!(initial_offset_size > 0);
1050
1051        // Appends a third row
1052        let mut writer = NippyJarWriter::new(nippy).unwrap();
1053        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1054        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1055
1056        // Makes sure it doesn't write the last one offset (which is the expected file data size)
1057        let _ = writer.offsets_mut().pop();
1058
1059        // `commit_offsets` is not a pub function. we call it here to simulate the shutdown before
1060        // it can flush nippy.rows (config) to disk.
1061        writer.commit_offsets().unwrap();
1062
1063        // Simulate an unexpected shutdown of the writer, before it can finish commit()
1064        drop(writer);
1065
1066        let nippy = NippyJar::load_without_header(file_path).unwrap();
1067        assert_eq!(initial_rows, nippy.rows);
1068
1069        // Data was written successfully
1070        let new_data_size =
1071            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1072        assert_eq!(new_data_size, initial_data_size + col1[2].len() + col2[2].len());
1073
1074        // It should be + 16 (two columns were added), but there's a missing one (the one we pop)
1075        assert_eq!(
1076            initial_offset_size + 8,
1077            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize
1078        );
1079
1080        // Writer will execute a consistency check and verify first that the offset list on disk
1081        // doesn't match the nippy.rows, and prune it. Then, it will prune the data file
1082        // accordingly as well.
1083        let writer = NippyJarWriter::new(nippy).unwrap();
1084        assert_eq!(initial_rows, writer.rows());
1085        assert_eq!(
1086            initial_offset_size,
1087            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len() as usize
1088        );
1089        assert_eq!(
1090            initial_data_size,
1091            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize
1092        );
1093    }
1094
1095    fn test_append_consistency_no_commit(file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1096        let nippy = NippyJar::load_without_header(file_path).unwrap();
1097
1098        // Set the baseline that should be unwinded to
1099        let initial_rows = nippy.rows;
1100        let initial_data_size =
1101            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1102        let initial_offset_size =
1103            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize;
1104        assert!(initial_data_size > 0);
1105        assert!(initial_offset_size > 0);
1106
1107        // Appends a third row, so we have an offset list in memory, which is not flushed to disk,
1108        // while the data has been.
1109        let mut writer = NippyJarWriter::new(nippy).unwrap();
1110        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1111        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1112
1113        // Simulate an unexpected shutdown of the writer, before it can call commit()
1114        drop(writer);
1115
1116        let nippy = NippyJar::load_without_header(file_path).unwrap();
1117        assert_eq!(initial_rows, nippy.rows);
1118
1119        // Data was written successfully
1120        let new_data_size =
1121            File::open(nippy.data_path()).unwrap().metadata().unwrap().len() as usize;
1122        assert_eq!(new_data_size, initial_data_size + col1[2].len() + col2[2].len());
1123
1124        // Since offsets only get written on commit(), this remains the same
1125        assert_eq!(
1126            initial_offset_size,
1127            File::open(nippy.offsets_path()).unwrap().metadata().unwrap().len() as usize
1128        );
1129
1130        // Writer will execute a consistency check and verify that the data file has more data than
1131        // it should, and resets it to the last offset of the list (on disk here)
1132        let writer = NippyJarWriter::new(nippy).unwrap();
1133        assert_eq!(initial_rows, writer.rows());
1134        assert_eq!(
1135            initial_data_size,
1136            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize
1137        );
1138    }
1139
1140    fn append_two_rows(num_columns: usize, file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1141        // Create and add 1 row
1142        {
1143            let nippy = NippyJar::new_without_header(num_columns, file_path);
1144            nippy.freeze_config().unwrap();
1145            assert_eq!(nippy.max_row_size, 0);
1146            assert_eq!(nippy.rows, 0);
1147
1148            let mut writer = NippyJarWriter::new(nippy).unwrap();
1149            assert_eq!(writer.column(), 0);
1150
1151            writer.append_column(Some(Ok(&col1[0]))).unwrap();
1152            assert_eq!(writer.column(), 1);
1153            assert!(writer.is_dirty());
1154
1155            writer.append_column(Some(Ok(&col2[0]))).unwrap();
1156            assert!(writer.is_dirty());
1157
1158            // Adding last column of a row resets writer and updates jar config
1159            assert_eq!(writer.column(), 0);
1160
1161            // One offset per column + 1 offset at the end representing the expected file data size
1162            assert_eq!(writer.offsets().len(), 3);
1163            let expected_data_file_size = *writer.offsets().last().unwrap();
1164            writer.commit().unwrap();
1165            assert!(!writer.is_dirty());
1166
1167            assert_eq!(writer.max_row_size(), col1[0].len() + col2[0].len());
1168            assert_eq!(writer.rows(), 1);
1169            assert_eq!(
1170                File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1171                1 + num_columns as u64 * 8 + 8
1172            );
1173            assert_eq!(
1174                File::open(writer.data_path()).unwrap().metadata().unwrap().len(),
1175                expected_data_file_size
1176            );
1177        }
1178
1179        // Load and add 1 row
1180        {
1181            let nippy = NippyJar::load_without_header(file_path).unwrap();
1182            // Check if it was committed successfully
1183            assert_eq!(nippy.max_row_size, col1[0].len() + col2[0].len());
1184            assert_eq!(nippy.rows, 1);
1185
1186            let mut writer = NippyJarWriter::new(nippy).unwrap();
1187            assert_eq!(writer.column(), 0);
1188
1189            writer.append_column(Some(Ok(&col1[1]))).unwrap();
1190            assert_eq!(writer.column(), 1);
1191
1192            writer.append_column(Some(Ok(&col2[1]))).unwrap();
1193
1194            // Adding last column of a row resets writer and updates jar config
1195            assert_eq!(writer.column(), 0);
1196
1197            // One offset per column + 1 offset at the end representing the expected file data size
1198            assert_eq!(writer.offsets().len(), 3);
1199            let expected_data_file_size = *writer.offsets().last().unwrap();
1200            writer.commit().unwrap();
1201
1202            assert_eq!(writer.max_row_size(), col1[0].len() + col2[0].len());
1203            assert_eq!(writer.rows(), 2);
1204            assert_eq!(
1205                File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1206                1 + writer.rows() as u64 * num_columns as u64 * 8 + 8
1207            );
1208            assert_eq!(
1209                File::open(writer.data_path()).unwrap().metadata().unwrap().len(),
1210                expected_data_file_size
1211            );
1212        }
1213    }
1214
1215    fn prune_rows(num_columns: usize, file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>]) {
1216        let nippy = NippyJar::load_without_header(file_path).unwrap();
1217        let mut writer = NippyJarWriter::new(nippy).unwrap();
1218
1219        // Appends a third row, so we have an offset list in memory, which is not flushed to disk
1220        writer.append_column(Some(Ok(&col1[2]))).unwrap();
1221        writer.append_column(Some(Ok(&col2[2]))).unwrap();
1222        assert!(writer.is_dirty());
1223
1224        // This should prune from the on-memory offset list and ondisk offset list
1225        writer.prune_rows(2).unwrap();
1226        assert_eq!(writer.rows(), 1);
1227
1228        assert_eq!(
1229            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(),
1230            1 + writer.rows() as u64 * num_columns as u64 * 8 + 8
1231        );
1232
1233        let expected_data_size = col1[0].len() + col2[0].len();
1234        assert_eq!(
1235            File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize,
1236            expected_data_size
1237        );
1238
1239        let nippy = NippyJar::load_without_header(file_path).unwrap();
1240        {
1241            let data_reader = nippy.open_data_reader().unwrap();
1242            // there are only two valid offsets. so index 2 actually represents the expected file
1243            // data size.
1244            assert_eq!(data_reader.offset(2).unwrap(), expected_data_size as u64);
1245        }
1246
1247        // This should prune from the ondisk offset list and clear the jar.
1248        let mut writer = NippyJarWriter::new(nippy).unwrap();
1249        writer.prune_rows(1).unwrap();
1250        assert!(writer.is_dirty());
1251
1252        assert_eq!(writer.rows(), 0);
1253        assert_eq!(writer.max_row_size(), 0);
1254        assert_eq!(File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize, 0);
1255        // Offset size byte (1) + final offset (8) = 9 bytes
1256        assert_eq!(
1257            File::open(writer.offsets_path()).unwrap().metadata().unwrap().len() as usize,
1258            9
1259        );
1260        writer.commit().unwrap();
1261        assert!(!writer.is_dirty());
1262    }
1263
1264    fn simulate_interrupted_prune(
1265        num_columns: usize,
1266        file_path: &Path,
1267        num_rows: u64,
1268        missing_offsets: u64,
1269    ) {
1270        let nippy = NippyJar::load_without_header(file_path).unwrap();
1271        let reader = nippy.open_data_reader().unwrap();
1272        let offsets_file =
1273            OpenOptions::new().read(true).write(true).open(nippy.offsets_path()).unwrap();
1274        let offsets_len = 1 + num_rows * num_columns as u64 * 8 + 8;
1275        assert_eq!(offsets_len, offsets_file.metadata().unwrap().len());
1276
1277        let data_file = OpenOptions::new().read(true).write(true).open(nippy.data_path()).unwrap();
1278        let data_len = reader.reverse_offset(0).unwrap();
1279        assert_eq!(data_len, data_file.metadata().unwrap().len());
1280
1281        // each data column is 32 bytes long
1282        // by deleting from the data file, the `consistency_check` will go through both branches:
1283        //      when the offset list wasn't updated after clearing the data (data_len > last
1284        // offset).      fixing above, will lead to offset count not match the rows (*
1285        // columns) of the configuration file
1286        data_file.set_len(data_len - 32 * missing_offsets).unwrap();
1287
1288        // runs the consistency check.
1289        let _ = NippyJarWriter::new(nippy).unwrap();
1290    }
1291
1292    fn append_row(writer: &mut NippyJarWriter, col1: &[Vec<u8>], col2: &[Vec<u8>], row: usize) {
1293        writer.append_column(Some(Ok(&col1[row]))).unwrap();
1294        writer.append_column(Some(Ok(&col2[row]))).unwrap();
1295    }
1296
1297    /// Asserts that the jar on disk is consistent, claims exactly `rows` rows, and that every
1298    /// one of them reads back as the corresponding entry of `col1` and `col2`.
1299    fn assert_jar_rows(file_path: &Path, col1: &[Vec<u8>], col2: &[Vec<u8>], rows: usize) {
1300        let nippy = NippyJar::load_without_header(file_path).unwrap();
1301        assert_eq!(nippy.rows, rows);
1302        NippyJarChecker::new(NippyJar::load_without_header(file_path).unwrap())
1303            .check_consistency()
1304            .unwrap();
1305
1306        let mut cursor = NippyJarCursor::new(&nippy).unwrap();
1307        for row in 0..rows {
1308            let values = cursor.row_by_number(row).unwrap().unwrap();
1309            assert_eq!((values[0], values[1]), (col1[row].as_slice(), col2[row].as_slice()));
1310        }
1311        assert!(cursor.row_by_number(rows).unwrap().is_none());
1312    }
1313
1314    #[test]
1315    fn test_zstd_013_compatibility() {
1316        // These jars and the dictionary-compressed block were written with zstd 0.13.3.
1317        let col1 = (0..100u8).map(|i| vec![i; 32]).collect::<Vec<_>>();
1318        let col2 = (0..100u8)
1319            .map(|i| (0..32u8).map(|j| i.wrapping_add(j)).collect::<Vec<_>>())
1320            .collect::<Vec<_>>();
1321        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zstd-0.13");
1322        let directory = tempfile::tempdir().unwrap();
1323        for name in ["dictionary", "plain"] {
1324            let path = directory.path().join(name);
1325            for extension in ["", CONFIG_FILE_EXTENSION, OFFSETS_FILE_EXTENSION] {
1326                reth_fs_util::write(
1327                    path.with_extension(extension),
1328                    reth_fs_util::read(fixtures.join(name).with_extension(extension)).unwrap(),
1329                )
1330                .unwrap();
1331            }
1332            assert_jar_rows(&path, &col1, &col2, 100);
1333        }
1334
1335        let jar = NippyJar::load_without_header(&directory.path().join("dictionary")).unwrap();
1336        let Some(Compressors::Zstd(zstd)) = jar.compressor() else {
1337            panic!("Expected Zstd compressor")
1338        };
1339        let encoded = include_bytes!("../tests/fixtures/zstd-0.13/dictionary.block");
1340        let mut output = Vec::with_capacity(32);
1341        compression::Zstd::decompress_with_dictionary(
1342            encoded,
1343            &mut output,
1344            &mut zstd.decompressors().unwrap()[0],
1345        )
1346        .unwrap();
1347        assert_eq!(output, col1[5]);
1348    }
1349
1350    #[test]
1351    fn test_writer_resumes_interrupted_creation() {
1352        let file_path = tempfile::NamedTempFile::new().unwrap();
1353        let path = file_path.path();
1354
1355        // Crash after creating the data and offsets files, before writing the offsets header.
1356        std::fs::write(path, b"junk").unwrap();
1357        std::fs::write(path.with_extension(OFFSETS_FILE_EXTENSION), b"").unwrap();
1358        assert!(!path.with_extension(CONFIG_FILE_EXTENSION).exists());
1359
1360        let mut writer = NippyJarWriter::new(NippyJar::new_without_header(1, path)).unwrap();
1361        writer.append_column(Some(Ok(&b"row"[..]))).unwrap();
1362        writer.commit().unwrap();
1363        drop(writer);
1364
1365        let jar = NippyJar::load_without_header(path).unwrap();
1366        assert_eq!(jar.rows(), 1);
1367        NippyJarChecker::new(jar).check_consistency().unwrap();
1368        assert_eq!(std::fs::read(path).unwrap(), b"row");
1369    }
1370}