Skip to main content

reth_nippy_jar/
writer.rs

1use crate::{
2    compression::Compression, ColumnResult, NippyJar, NippyJarChecker, NippyJarError,
3    NippyJarHeader,
4};
5use std::{
6    fs::{File, OpenOptions},
7    io::{BufWriter, Read, Seek, SeekFrom, Write},
8    path::Path,
9};
10
11/// Size of one offset in bytes.
12pub(crate) const OFFSET_SIZE_BYTES: u8 = 8;
13
14/// Writer of [`NippyJar`]. Handles table data and offsets only.
15///
16/// Table data is written directly to disk, while offsets and configuration need to be flushed by
17/// calling `commit()`.
18///
19/// ## Offset file layout
20/// The first byte is the size of a single offset in bytes, `m`.
21/// Then, the file contains `n` entries, each with a size of `m`. Each entry represents an offset,
22/// except for the last entry, which represents both the total size of the data file, as well as the
23/// next offset to write new data to.
24///
25/// ## Data file layout
26/// The data file is represented just as a sequence of bytes of data without any delimiters
27#[derive(Debug)]
28pub struct NippyJarWriter<H: NippyJarHeader = ()> {
29    /// Associated [`NippyJar`], containing all necessary configurations for data
30    /// handling.
31    jar: NippyJar<H>,
32    /// File handle to where the data is stored.
33    data_file: BufWriter<File>,
34    /// File handle to where the offsets are stored.
35    offsets_file: BufWriter<File>,
36    /// Temporary buffer to reuse when compressing data.
37    tmp_buf: Vec<u8>,
38    /// Used to find the maximum uncompressed size of a row in a jar.
39    uncompressed_row_size: usize,
40    /// Partial offset list which hasn't been flushed to disk.
41    offsets: Vec<u64>,
42    /// Column where writer is going to write next.
43    column: usize,
44    /// Whether the writer has changed data that needs to be committed.
45    dirty: bool,
46    pending_truncation: Option<PendingTruncation>,
47}
48
49#[derive(Debug, Clone, Copy)]
50struct PendingTruncation {
51    offsets_len: u64,
52    data_len: u64,
53}
54
55impl<H: NippyJarHeader> NippyJarWriter<H> {
56    /// Creates a [`NippyJarWriter`] from [`NippyJar`].
57    ///
58    /// It will **always** attempt to heal any inconsistent state when called. A jar without a
59    /// config file is treated as never initialized, and its data and offsets files are truncated.
60    pub fn new(jar: NippyJar<H>) -> Result<Self, NippyJarError> {
61        let (data_file, offsets_file, is_created) =
62            Self::create_or_open_files(jar.data_path(), &jar.offsets_path(), &jar.config_path())?;
63
64        let (jar, data_file, offsets_file) = if is_created {
65            // Makes sure we don't have dangling data and offset files when we just created the file
66            jar.freeze_config()?;
67
68            (jar, BufWriter::new(data_file), BufWriter::new(offsets_file))
69        } else {
70            // If we are opening a previously created jar, we need to check its consistency, and
71            // make changes if necessary.
72            let mut checker = NippyJarChecker::new(jar);
73            checker.ensure_consistency()?;
74
75            let NippyJarChecker { jar, data_file, offsets_file } = checker;
76
77            // Calling ensure_consistency, will fill data_file and offsets_file
78            (jar, data_file.expect("qed"), offsets_file.expect("qed"))
79        };
80
81        let mut writer = Self {
82            jar,
83            data_file,
84            offsets_file,
85            tmp_buf: Vec::with_capacity(1_000_000),
86            uncompressed_row_size: 0,
87            offsets: Vec::with_capacity(1_000_000),
88            column: 0,
89            dirty: false,
90            pending_truncation: None,
91        };
92
93        if !is_created {
94            // Commit any potential heals done above.
95            writer.commit()?;
96        }
97
98        Ok(writer)
99    }
100
101    /// Returns a reference to `H` of [`NippyJar`]
102    pub const fn user_header(&self) -> &H {
103        &self.jar.user_header
104    }
105
106    /// Returns a mutable reference to `H` of [`NippyJar`].
107    ///
108    /// Since there's no way of knowing if `H` has been actually changed, this sets `self.dirty` to
109    /// true.
110    pub const fn user_header_mut(&mut self) -> &mut H {
111        self.dirty = true;
112        &mut self.jar.user_header
113    }
114
115    /// Returns whether there are changes that need to be committed.
116    pub const fn is_dirty(&self) -> bool {
117        self.dirty
118    }
119
120    /// Sets writer as dirty.
121    pub const fn set_dirty(&mut self) {
122        self.dirty = true
123    }
124
125    /// Gets total writer rows in jar.
126    pub const fn rows(&self) -> usize {
127        self.jar.rows()
128    }
129
130    /// Consumes the writer and returns the associated [`NippyJar`].
131    pub fn into_jar(self) -> NippyJar<H> {
132        self.jar
133    }
134
135    fn create_or_open_files(
136        data: &Path,
137        offsets: &Path,
138        config: &Path,
139    ) -> Result<(File, File, bool), NippyJarError> {
140        // The config is written last on creation, so a missing one means an interrupted
141        // initialization that has to start over.
142        let is_created = !data.exists() || !offsets.exists() || !config.exists();
143
144        if !data.exists() {
145            // File::create is write-only (no reading possible)
146            File::create(data)?;
147        }
148
149        let mut data_file = OpenOptions::new().read(true).write(true).open(data)?;
150        if is_created {
151            // Drop stale data left behind by a previous jar at the same path.
152            data_file.set_len(0)?;
153        }
154        data_file.seek(SeekFrom::End(0))?;
155
156        if !offsets.exists() {
157            // File::create is write-only (no reading possible)
158            File::create(offsets)?;
159        }
160
161        let mut offsets_file = OpenOptions::new().read(true).write(true).open(offsets)?;
162        if is_created {
163            data_file.set_len(0)?;
164            data_file.seek(SeekFrom::Start(0))?;
165            offsets_file.set_len(0)?;
166
167            let mut buf = Vec::with_capacity(1 + OFFSET_SIZE_BYTES as usize);
168
169            // First byte of the offset file is the size of one offset in bytes
170            buf.write_all(&[OFFSET_SIZE_BYTES])?;
171
172            // The last offset should always represent the data file len, which is 0 on
173            // creation.
174            buf.write_all(&[0; OFFSET_SIZE_BYTES as usize])?;
175
176            offsets_file.write_all(&buf)?;
177            // Drop stale offsets left behind by a previous jar at the same path. Truncating after
178            // the header is written never leaves the file empty.
179            offsets_file.set_len(buf.len() as u64)?;
180            offsets_file.seek(SeekFrom::End(0))?;
181        }
182
183        Ok((data_file, offsets_file, is_created))
184    }
185
186    /// Appends rows to data file.  `fn commit()` should be called to flush offsets and config to
187    /// disk.
188    ///
189    /// `column_values_per_row`: A vector where each element is a column's values in sequence,
190    /// corresponding to each row. The vector's length equals the number of columns.
191    pub fn append_rows(
192        &mut self,
193        column_values_per_row: Vec<impl IntoIterator<Item = ColumnResult<impl AsRef<[u8]>>>>,
194        num_rows: u64,
195    ) -> Result<(), NippyJarError> {
196        let mut column_iterators = column_values_per_row
197            .into_iter()
198            .map(|v| v.into_iter())
199            .collect::<Vec<_>>()
200            .into_iter();
201
202        for _ in 0..num_rows {
203            let mut iterators = Vec::with_capacity(self.jar.columns);
204
205            for mut column_iter in column_iterators {
206                self.append_column(column_iter.next())?;
207
208                iterators.push(column_iter);
209            }
210
211            column_iterators = iterators.into_iter();
212        }
213
214        Ok(())
215    }
216
217    /// Appends a column to data file. `fn commit()` should be called to flush offsets and config to
218    /// disk.
219    pub fn append_column(
220        &mut self,
221        column: Option<ColumnResult<impl AsRef<[u8]>>>,
222    ) -> Result<(), NippyJarError> {
223        self.apply_pending_truncation()?;
224        self.dirty = true;
225
226        match column {
227            Some(Ok(value)) => {
228                if self.offsets.is_empty() {
229                    // Represents the offset of the soon to be appended data column
230                    self.offsets.push(self.data_file.stream_position()?);
231                }
232
233                let written = self.write_column(value.as_ref())?;
234
235                // Last offset represents the size of the data file if no more data is to be
236                // appended. Otherwise, represents the offset of the next data item.
237                self.offsets.push(self.offsets.last().expect("qed") + written as u64);
238            }
239            None => {
240                return Err(NippyJarError::UnexpectedMissingValue(
241                    self.jar.rows as u64,
242                    self.column as u64,
243                ))
244            }
245            Some(Err(err)) => return Err(err.into()),
246        }
247
248        Ok(())
249    }
250
251    /// Writes column to data file. If it's the last column of the row, call `finalize_row()`
252    fn write_column(&mut self, value: &[u8]) -> Result<usize, NippyJarError> {
253        self.uncompressed_row_size += value.len();
254        let len = if let Some(compression) = &self.jar.compressor {
255            let before = self.tmp_buf.len();
256            let len = compression.compress_to(value, &mut self.tmp_buf)?;
257            self.data_file.write_all(&self.tmp_buf[before..before + len])?;
258            len
259        } else {
260            self.data_file.write_all(value)?;
261            value.len()
262        };
263
264        self.column += 1;
265
266        if self.jar.columns == self.column {
267            self.finalize_row();
268        }
269
270        Ok(len)
271    }
272
273    /// Prunes rows from data and offsets file and updates its configuration on disk
274    pub fn prune_rows(&mut self, num_rows: usize) -> Result<(), NippyJarError> {
275        self.prune_rows_deferred(num_rows)?;
276        self.apply_pending_truncation()?;
277        Ok(())
278    }
279
280    /// Prunes rows like [`Self::prune_rows`], but leaves the committed rows' bytes on disk until
281    /// [`Self::apply_pending_truncation`].
282    pub fn prune_rows_deferred(&mut self, num_rows: usize) -> Result<(), NippyJarError> {
283        self.apply_pending_truncation()?;
284        self.dirty = true;
285
286        self.offsets_file.flush()?;
287        self.data_file.flush()?;
288
289        // Each column of a row is one offset
290        let num_offsets = num_rows * self.jar.columns;
291
292        // Calculate the number of offsets to prune from in-memory list
293        let offsets_prune_count = num_offsets.min(self.offsets.len().saturating_sub(1)); // last element is the expected size of the data file
294        let remaining_to_prune = num_offsets.saturating_sub(offsets_prune_count);
295
296        // Prune in-memory offsets if needed
297        if offsets_prune_count > 0 {
298            // Determine new length based on the offset to prune up to
299            let new_len = self.offsets[(self.offsets.len() - 1) - offsets_prune_count]; // last element is the expected size of the data file
300            self.offsets.truncate(self.offsets.len() - offsets_prune_count);
301
302            // Truncate the data file to the new length
303            self.data_file.get_mut().set_len(new_len)?;
304        }
305
306        // Prune from on-disk offset list if there are still rows left to prune
307        if remaining_to_prune > 0 {
308            // Get the current length of the on-disk offset file
309            let length = self.offsets_file.get_ref().metadata()?.len();
310
311            // Handle non-empty offset file
312            if length > 1 {
313                // first byte is reserved for `bytes_per_offset`, which is 8 initially.
314                let num_offsets = (length - 1) / OFFSET_SIZE_BYTES as u64;
315
316                if remaining_to_prune as u64 > num_offsets {
317                    return Err(NippyJarError::InvalidPruning(
318                        num_offsets,
319                        remaining_to_prune as u64,
320                    ))
321                }
322
323                let new_num_offsets = num_offsets.saturating_sub(remaining_to_prune as u64);
324
325                // If all rows are to be pruned
326                if new_num_offsets <= 1 {
327                    // <= 1 because the one offset would actually be the expected file data size
328                    //
329                    // When no rows remain, keep the offset size byte and the final offset (data
330                    // file size = 0). This maintains the same structure as when
331                    // a file is initially created.
332                    // See `NippyJarWriter::create_or_open_files` for the initial file format.
333                    self.pending_truncation = Some(PendingTruncation {
334                        offsets_len: 1 + OFFSET_SIZE_BYTES as u64,
335                        data_len: 0,
336                    });
337                } else {
338                    // Calculate the new length for the on-disk offset list
339                    let new_len = 1 + new_num_offsets * OFFSET_SIZE_BYTES as u64;
340                    // Seek to the position of the last offset
341                    self.offsets_file
342                        .seek(SeekFrom::Start(new_len.saturating_sub(OFFSET_SIZE_BYTES as u64)))?;
343                    // Read the last offset value
344                    let mut last_offset = [0u8; OFFSET_SIZE_BYTES as usize];
345                    self.offsets_file.get_ref().read_exact(&mut last_offset)?;
346                    let last_offset = u64::from_le_bytes(last_offset);
347
348                    // Update the lengths of both the offsets and data files
349                    self.pending_truncation =
350                        Some(PendingTruncation { offsets_len: new_len, data_len: last_offset });
351                }
352            } else {
353                return Err(NippyJarError::InvalidPruning(0, remaining_to_prune as u64))
354            }
355        }
356
357        if remaining_to_prune > 0 {
358            // Every uncommitted row was pruned, so the remaining in-memory offset (the previous
359            // data file end) is stale: the on-disk prune shrank the data file below it.
360            self.offsets.clear();
361        } else {
362            // The config written below counts the uncommitted rows that were not pruned, so their
363            // offsets must be on disk too. If we crash before the config is written, the offsets
364            // file is longer than the config expects and the checker heals it as an interrupted
365            // append.
366            self.commit_offsets_inner()?;
367        }
368
369        self.offsets_file.get_ref().sync_all()?;
370        self.data_file.get_ref().sync_all()?;
371
372        self.offsets_file.seek(SeekFrom::End(0))?;
373        self.data_file.seek(SeekFrom::End(0))?;
374
375        self.jar.rows = self.jar.rows.saturating_sub(num_rows);
376        if self.jar.rows == 0 {
377            self.jar.max_row_size = 0;
378        }
379        self.jar.freeze_config()?;
380
381        Ok(())
382    }
383
384    /// Truncates the files to the rows kept by [`Self::prune_rows_deferred`].
385    ///
386    /// Returns `false` if no truncation was pending.
387    pub fn apply_pending_truncation(&mut self) -> Result<bool, NippyJarError> {
388        let Some(PendingTruncation { offsets_len, data_len }) = self.pending_truncation else {
389            return Ok(false)
390        };
391
392        self.offsets_file.get_mut().set_len(offsets_len)?;
393        self.data_file.get_mut().set_len(data_len)?;
394
395        self.offsets_file.get_ref().sync_all()?;
396        self.data_file.get_ref().sync_all()?;
397
398        self.offsets_file.seek(SeekFrom::End(0))?;
399        self.data_file.seek(SeekFrom::End(0))?;
400        self.pending_truncation = None;
401
402        Ok(true)
403    }
404
405    /// Updates [`NippyJar`] with the new row count and maximum uncompressed row size, while
406    /// resetting internal fields.
407    fn finalize_row(&mut self) {
408        self.jar.max_row_size = self.jar.max_row_size.max(self.uncompressed_row_size);
409        self.jar.rows += 1;
410
411        self.tmp_buf.clear();
412        self.uncompressed_row_size = 0;
413        self.column = 0;
414    }
415
416    /// Commits configuration and offsets to disk. It drains the internal offset list.
417    pub fn commit(&mut self) -> Result<(), NippyJarError> {
418        self.sync_all()?;
419        self.finalize()?;
420        Ok(())
421    }
422
423    /// Syncs data and offsets to disk.
424    ///
425    /// This does NOT commit the configuration. Call [`Self::finalize`] after to write the
426    /// configuration and mark the writer as clean.
427    pub fn sync_all(&mut self) -> Result<(), NippyJarError> {
428        self.data_file.flush()?;
429        self.data_file.get_ref().sync_all()?;
430
431        self.commit_offsets()?;
432        Ok(())
433    }
434
435    /// Commits configuration to disk and marks the writer as clean.
436    ///
437    /// Must be called after [`Self::sync_all`] to complete the commit.
438    pub fn finalize(&mut self) -> Result<(), NippyJarError> {
439        // Flushes `max_row_size` and total `rows` to disk.
440        self.jar.freeze_config()?;
441        self.dirty = false;
442
443        Ok(())
444    }
445
446    /// Commits changes to the data file and offsets without synchronizing all data to disk.
447    ///
448    /// This function flushes the buffered data to the data file and commits the offsets,
449    /// but it does not guarantee that all data is synchronized to persistent storage.
450    #[cfg(feature = "test-utils")]
451    pub fn commit_without_sync_all(&mut self) -> Result<(), NippyJarError> {
452        self.data_file.flush()?;
453
454        self.commit_offsets_without_sync_all()?;
455
456        // Flushes `max_row_size` and total `rows` to disk.
457        self.jar.freeze_config()?;
458        self.dirty = false;
459
460        Ok(())
461    }
462
463    /// Flushes offsets to disk.
464    pub(crate) fn commit_offsets(&mut self) -> Result<(), NippyJarError> {
465        self.commit_offsets_inner()?;
466        self.offsets_file.get_ref().sync_all()?;
467
468        Ok(())
469    }
470
471    #[cfg(feature = "test-utils")]
472    fn commit_offsets_without_sync_all(&mut self) -> Result<(), NippyJarError> {
473        self.commit_offsets_inner()
474    }
475
476    /// Flushes offsets to disk.
477    ///
478    /// CAUTION: Does not call `sync_all` on the offsets file and requires a manual call to
479    /// `self.offsets_file.get_ref().sync_all()`.
480    fn commit_offsets_inner(&mut self) -> Result<(), NippyJarError> {
481        // The last offset on disk can be the first offset of `self.offsets` given how
482        // `append_column()` works alongside commit. So we need to skip it.
483        let mut last_offset_ondisk = if self.offsets_file.get_ref().metadata()?.len() > 1 {
484            self.offsets_file.seek(SeekFrom::End(-(OFFSET_SIZE_BYTES as i64)))?;
485            let mut buf = [0u8; OFFSET_SIZE_BYTES as usize];
486            self.offsets_file.get_ref().read_exact(&mut buf)?;
487            Some(u64::from_le_bytes(buf))
488        } else {
489            None
490        };
491
492        self.offsets_file.seek(SeekFrom::End(0))?;
493
494        // Appends new offsets to disk
495        for offset in self.offsets.drain(..) {
496            if let Some(last_offset_ondisk) = last_offset_ondisk.take() &&
497                last_offset_ondisk == offset
498            {
499                continue
500            }
501            self.offsets_file.write_all(&offset.to_le_bytes())?;
502        }
503        self.offsets_file.flush()?;
504
505        Ok(())
506    }
507
508    /// Returns the maximum row size for the associated [`NippyJar`].
509    #[cfg(test)]
510    pub const fn max_row_size(&self) -> usize {
511        self.jar.max_row_size
512    }
513
514    /// Returns the column index of the current checker instance.
515    #[cfg(test)]
516    pub const fn column(&self) -> usize {
517        self.column
518    }
519
520    /// Returns a reference to the offsets vector.
521    #[cfg(test)]
522    pub fn offsets(&self) -> &[u64] {
523        &self.offsets
524    }
525
526    /// Returns a mutable reference to the offsets vector.
527    #[cfg(test)]
528    pub const fn offsets_mut(&mut self) -> &mut Vec<u64> {
529        &mut self.offsets
530    }
531
532    /// Returns the path to the offsets file for the associated [`NippyJar`].
533    #[cfg(test)]
534    pub fn offsets_path(&self) -> std::path::PathBuf {
535        self.jar.offsets_path()
536    }
537
538    /// Returns the path to the data file for the associated [`NippyJar`].
539    #[cfg(test)]
540    pub fn data_path(&self) -> &Path {
541        self.jar.data_path()
542    }
543
544    /// Returns a mutable reference to the buffered writer for the data file.
545    #[cfg(any(test, feature = "test-utils"))]
546    pub const fn data_file(&mut self) -> &mut BufWriter<File> {
547        &mut self.data_file
548    }
549
550    /// Returns a reference to the associated [`NippyJar`] instance.
551    #[cfg(any(test, feature = "test-utils"))]
552    pub const fn jar(&self) -> &NippyJar<H> {
553        &self.jar
554    }
555}