Skip to main content

reth_static_file_types/
changeset_offsets.rs

1//! Changeset offset sidecar file I/O.
2//!
3//! Provides append-only writing and O(1) random-access reading for changeset offsets.
4//! The file format is fixed-width 16-byte records: `[offset: u64 LE][num_changes: u64 LE]`.
5
6use crate::ChangesetOffset;
7use std::{
8    fs::{File, OpenOptions},
9    io::{self, Write},
10    os::unix::fs::FileExt,
11    path::Path,
12};
13
14/// Writer for appending changeset offsets to a sidecar file.
15#[derive(Debug)]
16pub struct ChangesetOffsetWriter {
17    file: File,
18    /// Number of records written.
19    records_written: u64,
20    /// Records to keep once the deferred truncation is applied, or `None` if none is pending.
21    pending_truncation: Option<u64>,
22}
23
24impl ChangesetOffsetWriter {
25    /// Record size in bytes.
26    const RECORD_SIZE: usize = 16;
27
28    /// Opens or creates the changeset offset file for appending.
29    ///
30    /// The file is healed to match `committed_len` (from the segment header):
31    /// - Partial records (from crash mid-write) are truncated to record boundary
32    /// - Extra complete records (from crash after sidecar sync but before header commit) are
33    ///   truncated to match the committed length
34    /// - If the file has fewer records than committed, returns an error (data corruption)
35    ///
36    /// This mirrors `NippyJar`'s healing behavior where config/header is the commit boundary.
37    pub fn new(path: impl AsRef<Path>, committed_len: u64) -> io::Result<Self> {
38        let file = OpenOptions::new()
39            .create(true)
40            .truncate(false)
41            .read(true)
42            .write(true)
43            .open(path.as_ref())?;
44
45        let file_len = file.metadata()?.len();
46        let remainder = file_len % Self::RECORD_SIZE as u64;
47
48        // First, truncate any partial record from crash mid-write
49        let aligned_len = if remainder != 0 {
50            let truncated_len = file_len - remainder;
51            tracing::warn!(
52                target: "reth::static_file",
53                path = %path.as_ref().display(),
54                original_len = file_len,
55                truncated_len,
56                "Truncating partial changeset offset record"
57            );
58            file.set_len(truncated_len)?;
59            file.sync_all()?; // Sync required for crash safety
60            truncated_len
61        } else {
62            file_len
63        };
64
65        let records_in_file = aligned_len / Self::RECORD_SIZE as u64;
66
67        // Heal sidecar to match committed header length
68        match records_in_file.cmp(&committed_len) {
69            std::cmp::Ordering::Greater => {
70                // Sidecar has uncommitted records from a crash - truncate them
71                let target_len = committed_len * Self::RECORD_SIZE as u64;
72                tracing::warn!(
73                    target: "reth::static_file",
74                    path = %path.as_ref().display(),
75                    sidecar_records = records_in_file,
76                    committed_len,
77                    "Truncating uncommitted changeset offset records after crash recovery"
78                );
79                file.set_len(target_len)?;
80                file.sync_all()?; // Sync required for crash safety
81            }
82            std::cmp::Ordering::Less => {
83                // INVARIANT VIOLATION: This should be impossible if healing ran correctly.
84                //
85                // All code paths call `heal_changeset_sidecar()` before this function, which
86                // validates the sidecar against NippyJar state and corrects the header to match
87                // the actual file size. Therefore, `committed_len` should always equal or exceed
88                // `records_in_file` when this function is called.
89                //
90                // If we reach this error, it indicates:
91                // - A bug in the healing logic (header not corrected properly)
92                // - This function was called directly without going through healing
93                // - External corruption occurred between healing and opening (extremely unlikely)
94                return Err(io::Error::new(
95                    io::ErrorKind::InvalidData,
96                    format!(
97                        "INVARIANT VIOLATION: Changeset offset sidecar has {} records but header expects {} \
98                         (healing should have prevented this - possible bug in healing logic): {}",
99                        records_in_file,
100                        committed_len,
101                        path.as_ref().display()
102                    ),
103                ));
104            }
105            std::cmp::Ordering::Equal => {}
106        }
107
108        let records_written = committed_len;
109        let file = OpenOptions::new().create(true).append(true).open(path)?;
110
111        Ok(Self { file, records_written, pending_truncation: None })
112    }
113
114    /// Appends a single changeset offset record.
115    pub fn append(&mut self, offset: &ChangesetOffset) -> io::Result<()> {
116        self.apply_pending_truncation()?;
117        let mut buf = [0u8; Self::RECORD_SIZE];
118        buf[..8].copy_from_slice(&offset.offset().to_le_bytes());
119        buf[8..].copy_from_slice(&offset.num_changes().to_le_bytes());
120        self.file.write_all(&buf)?;
121        self.records_written += 1;
122        Ok(())
123    }
124
125    /// Appends multiple changeset offset records.
126    pub fn append_many(&mut self, offsets: &[ChangesetOffset]) -> io::Result<()> {
127        for offset in offsets {
128            self.append(offset)?;
129        }
130        Ok(())
131    }
132
133    /// Syncs all data to disk. Must be called before committing the header.
134    pub fn sync(&mut self) -> io::Result<()> {
135        self.file.sync_all()
136    }
137
138    /// Truncates the file to contain exactly `len` records and syncs to disk.
139    /// Used after prune operations to reclaim space.
140    ///
141    /// The sync is required for crash safety - without it, a crash could
142    /// resurrect the old file length.
143    pub fn truncate(&mut self, len: u64) -> io::Result<()> {
144        self.file.set_len(len * Self::RECORD_SIZE as u64)?;
145        self.file.sync_all()?;
146        self.pending_truncation = None;
147        self.records_written = len;
148        Ok(())
149    }
150
151    /// Drops records like [`Self::truncate`], but leaves them on disk until
152    /// [`Self::apply_pending_truncation`].
153    pub const fn truncate_deferred(&mut self, len: u64) {
154        self.records_written = len;
155        self.pending_truncation = Some(len);
156    }
157
158    /// Truncates the file to the records kept by [`Self::truncate_deferred`].
159    ///
160    /// Returns `false` if no truncation was pending.
161    pub fn apply_pending_truncation(&mut self) -> io::Result<bool> {
162        let Some(len) = self.pending_truncation else { return Ok(false) };
163        self.truncate(len)?;
164        Ok(true)
165    }
166
167    /// Returns the number of records in the file.
168    pub const fn len(&self) -> u64 {
169        self.records_written
170    }
171
172    /// Returns true if the file is empty.
173    pub const fn is_empty(&self) -> bool {
174        self.records_written == 0
175    }
176}
177
178/// Reader for changeset offsets with O(1) random access.
179#[derive(Debug)]
180pub struct ChangesetOffsetReader {
181    file: File,
182    /// Cached file length in records.
183    len: u64,
184}
185
186impl ChangesetOffsetReader {
187    /// Record size in bytes.
188    const RECORD_SIZE: usize = 16;
189
190    /// Opens the changeset offset file for reading with an explicit length.
191    ///
192    /// The `len` parameter (from header metadata) bounds the reader - any records
193    /// beyond this length are ignored. This ensures we only read committed data.
194    pub fn new(path: impl AsRef<Path>, len: u64) -> io::Result<Self> {
195        let file = File::open(path)?;
196        Ok(Self { file, len })
197    }
198
199    /// Reads a single changeset offset by block index.
200    /// Returns None if index is out of bounds.
201    pub fn get(&self, block_index: u64) -> io::Result<Option<ChangesetOffset>> {
202        if block_index >= self.len {
203            return Ok(None);
204        }
205
206        let byte_pos = block_index * Self::RECORD_SIZE as u64;
207        let mut buf = [0u8; Self::RECORD_SIZE];
208        self.file.read_exact_at(&mut buf, byte_pos)?;
209
210        let offset = u64::from_le_bytes(buf[..8].try_into().unwrap());
211        let num_changes = u64::from_le_bytes(buf[8..].try_into().unwrap());
212
213        Ok(Some(ChangesetOffset::new(offset, num_changes)))
214    }
215
216    /// Reads a range of changeset offsets.
217    pub fn get_range(&self, start: u64, end: u64) -> io::Result<Vec<ChangesetOffset>> {
218        let end = end.min(self.len);
219        if start >= end {
220            return Ok(Vec::new());
221        }
222
223        let count = (end - start) as usize;
224        let byte_pos = start * Self::RECORD_SIZE as u64;
225
226        let mut result = Vec::with_capacity(count);
227        let mut buf = [0u8; Self::RECORD_SIZE];
228
229        for i in 0..count {
230            let pos = byte_pos + (i as u64) * Self::RECORD_SIZE as u64;
231            self.file.read_exact_at(&mut buf, pos)?;
232            let offset = u64::from_le_bytes(buf[..8].try_into().unwrap());
233            let num_changes = u64::from_le_bytes(buf[8..].try_into().unwrap());
234            result.push(ChangesetOffset::new(offset, num_changes));
235        }
236
237        Ok(result)
238    }
239
240    /// Returns the number of valid records.
241    pub const fn len(&self) -> u64 {
242        self.len
243    }
244
245    /// Returns true if there are no records.
246    pub const fn is_empty(&self) -> bool {
247        self.len == 0
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use tempfile::tempdir;
255
256    #[test]
257    fn test_write_and_read() {
258        let dir = tempdir().unwrap();
259        let path = dir.path().join("test.csoff");
260
261        // Write (new file, committed_len=0)
262        {
263            let mut writer = ChangesetOffsetWriter::new(&path, 0).unwrap();
264            writer.append(&ChangesetOffset::new(0, 5)).unwrap();
265            writer.append(&ChangesetOffset::new(5, 3)).unwrap();
266            writer.append(&ChangesetOffset::new(8, 10)).unwrap();
267            writer.sync().unwrap();
268            assert_eq!(writer.len(), 3);
269        }
270
271        // Read
272        {
273            let reader = ChangesetOffsetReader::new(&path, 3).unwrap();
274            assert_eq!(reader.len(), 3);
275
276            let entry = reader.get(0).unwrap().unwrap();
277            assert_eq!(entry.offset(), 0);
278            assert_eq!(entry.num_changes(), 5);
279
280            let entry = reader.get(1).unwrap().unwrap();
281            assert_eq!(entry.offset(), 5);
282            assert_eq!(entry.num_changes(), 3);
283
284            let entry = reader.get(2).unwrap().unwrap();
285            assert_eq!(entry.offset(), 8);
286            assert_eq!(entry.num_changes(), 10);
287
288            assert!(reader.get(3).unwrap().is_none());
289        }
290    }
291
292    #[test]
293    fn test_truncate() {
294        let dir = tempdir().unwrap();
295        let path = dir.path().join("test.csoff");
296
297        let mut writer = ChangesetOffsetWriter::new(&path, 0).unwrap();
298        writer.append(&ChangesetOffset::new(0, 1)).unwrap();
299        writer.append(&ChangesetOffset::new(1, 2)).unwrap();
300        writer.append(&ChangesetOffset::new(3, 3)).unwrap();
301        writer.sync().unwrap();
302
303        writer.truncate(2).unwrap();
304        assert_eq!(writer.len(), 2);
305
306        let reader = ChangesetOffsetReader::new(&path, 2).unwrap();
307        assert_eq!(reader.len(), 2);
308        assert!(reader.get(2).unwrap().is_none());
309    }
310
311    #[test]
312    fn test_partial_record_recovery() {
313        let dir = tempdir().unwrap();
314        let path = dir.path().join("test.csoff");
315
316        // Write 1 full record (16 bytes) + 8 trailing bytes (partial record)
317        {
318            let mut file = std::fs::File::create(&path).unwrap();
319            // Full record: offset=100, num_changes=5
320            file.write_all(&100u64.to_le_bytes()).unwrap();
321            file.write_all(&5u64.to_le_bytes()).unwrap();
322            // Partial record: only 8 bytes (incomplete)
323            file.write_all(&200u64.to_le_bytes()).unwrap();
324            file.sync_all().unwrap();
325        }
326
327        // Verify file has 24 bytes before opening with writer
328        assert_eq!(std::fs::metadata(&path).unwrap().len(), 24);
329
330        // Open with writer, committed_len=1 (header committed 1 record)
331        // Should truncate partial record and match committed length
332        let writer = ChangesetOffsetWriter::new(&path, 1).unwrap();
333        assert_eq!(writer.len(), 1);
334
335        // Verify file was truncated to 16 bytes
336        assert_eq!(std::fs::metadata(&path).unwrap().len(), 16);
337
338        // Verify the complete record is readable
339        let reader = ChangesetOffsetReader::new(&path, 1).unwrap();
340        assert_eq!(reader.len(), 1);
341        let entry = reader.get(0).unwrap().unwrap();
342        assert_eq!(entry.offset(), 100);
343        assert_eq!(entry.num_changes(), 5);
344    }
345
346    #[test]
347    fn test_len_bounds_reads() {
348        let dir = tempdir().unwrap();
349        let path = dir.path().join("test.csoff");
350
351        // Write 3 records
352        {
353            let mut writer = ChangesetOffsetWriter::new(&path, 0).unwrap();
354            writer.append(&ChangesetOffset::new(0, 10)).unwrap();
355            writer.append(&ChangesetOffset::new(10, 20)).unwrap();
356            writer.append(&ChangesetOffset::new(30, 30)).unwrap();
357            writer.sync().unwrap();
358            assert_eq!(writer.len(), 3);
359        }
360
361        // Open with len=2, ignoring the 3rd record
362        let reader = ChangesetOffsetReader::new(&path, 2).unwrap();
363        assert_eq!(reader.len(), 2);
364
365        // First two records should be readable
366        let entry0 = reader.get(0).unwrap().unwrap();
367        assert_eq!(entry0.offset(), 0);
368        assert_eq!(entry0.num_changes(), 10);
369
370        let entry1 = reader.get(1).unwrap().unwrap();
371        assert_eq!(entry1.offset(), 10);
372        assert_eq!(entry1.num_changes(), 20);
373
374        // Third record should be out of bounds (due to len=2)
375        assert!(reader.get(2).unwrap().is_none());
376
377        // get_range should also respect the len bound
378        let range = reader.get_range(0, 5).unwrap();
379        assert_eq!(range.len(), 2);
380    }
381
382    #[test]
383    fn test_truncate_uncommitted_records_on_open() {
384        // Simulates crash recovery where sidecar has more records than committed header length.
385        // ChangesetOffsetWriter::new() should automatically truncate to committed_len.
386        let dir = tempdir().unwrap();
387        let path = dir.path().join("test.csoff");
388
389        // Simulate: wrote 3 records, synced sidecar, but header only committed len=2
390        {
391            let mut writer = ChangesetOffsetWriter::new(&path, 0).unwrap();
392            writer.append(&ChangesetOffset::new(0, 5)).unwrap();
393            writer.append(&ChangesetOffset::new(5, 10)).unwrap();
394            writer.append(&ChangesetOffset::new(15, 7)).unwrap(); // uncommitted
395            writer.sync().unwrap();
396            assert_eq!(writer.len(), 3);
397        }
398
399        // On "restart", new() heals by truncating to committed length
400        let committed_len = 2u64;
401        {
402            let writer = ChangesetOffsetWriter::new(&path, committed_len).unwrap();
403            assert_eq!(writer.len(), 2); // Healed to committed length
404        }
405
406        // Verify file is now correct length and new appends go to the right place
407        {
408            let mut writer = ChangesetOffsetWriter::new(&path, 2).unwrap();
409            assert_eq!(writer.len(), 2);
410
411            // Append a new record - should be at index 2, not index 3
412            writer.append(&ChangesetOffset::new(15, 20)).unwrap();
413            writer.sync().unwrap();
414            assert_eq!(writer.len(), 3);
415        }
416
417        // Verify the records are correct
418        {
419            let reader = ChangesetOffsetReader::new(&path, 3).unwrap();
420            assert_eq!(reader.len(), 3);
421
422            let entry0 = reader.get(0).unwrap().unwrap();
423            assert_eq!(entry0.offset(), 0);
424            assert_eq!(entry0.num_changes(), 5);
425
426            let entry1 = reader.get(1).unwrap().unwrap();
427            assert_eq!(entry1.offset(), 5);
428            assert_eq!(entry1.num_changes(), 10);
429
430            // This should be the NEW record, not the old uncommitted one
431            let entry2 = reader.get(2).unwrap().unwrap();
432            assert_eq!(entry2.offset(), 15);
433            assert_eq!(entry2.num_changes(), 20); // Not 7 from the old uncommitted record
434        }
435    }
436
437    #[test]
438    fn test_sidecar_shorter_than_committed_errors() {
439        // If sidecar has fewer records than committed, it's data corruption - should error.
440        let dir = tempdir().unwrap();
441        let path = dir.path().join("test.csoff");
442
443        // Write 1 record
444        {
445            let mut writer = ChangesetOffsetWriter::new(&path, 0).unwrap();
446            writer.append(&ChangesetOffset::new(0, 5)).unwrap();
447            writer.sync().unwrap();
448        }
449
450        // Try to open with committed_len=3 (header claims more than file has)
451        let result = ChangesetOffsetWriter::new(&path, 3);
452        assert!(result.is_err());
453        let err = result.unwrap_err();
454        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
455    }
456}