Skip to main content

nodedb_wal/double_write/
buffer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The double-write buffer file and its write path.
4
5use std::fs::{File, OpenOptions};
6use std::io::{Read, Seek, SeekFrom, Write};
7use std::path::{Path, PathBuf};
8
9#[cfg(target_os = "linux")]
10use std::os::unix::fs::OpenOptionsExt as _;
11
12use crate::align::{AlignedBuf, DEFAULT_ALIGNMENT, is_aligned};
13use crate::error::{Result, WalError};
14use crate::record::{HEADER_SIZE, WalRecord};
15
16use super::layout::{
17    DWB_CAPACITY, DWB_HEADER_FIELDS, DWB_HEADER_STRIDE, DWB_MAGIC, DWB_SLOT_RECORD_MAX,
18    DWB_SLOT_STRIDE, SlotPrefix, slot_offset,
19};
20use super::metrics;
21use super::mode::DwbMode;
22use super::raw_io::{full_capacity_slice, pwrite_all, zero_tail};
23use super::staging::StagedRing;
24use super::status::{DwbMirror, DwbSkipReason};
25
26/// Double-write buffer file.
27pub struct DoubleWriteBuffer {
28    pub(super) file: File,
29    path: PathBuf,
30    pub(super) mode: DwbMode,
31    /// Current write position (circular, wraps at DWB_CAPACITY).
32    write_pos: u32,
33    /// Number of valid records in the buffer.
34    count: u32,
35    /// Sequence number the next slot write will carry. Resolved lazily on the
36    /// first write so that read-only openers (recovery) never pay for the
37    /// ring scan it needs.
38    next_seq: Option<u64>,
39    /// Whether there are deferred writes that haven't been fsynced.
40    pub(super) dirty: bool,
41    /// Slot images staged since the last flush. Allocated on the first
42    /// staged record.
43    stage: Option<StagedRing>,
44    /// Slots written to disk by this buffer since it was opened.
45    slots_written: u64,
46    /// Aligned header block (Direct mode only). Written on `flush()`.
47    header_buf: Option<AlignedBuf>,
48}
49
50impl std::fmt::Debug for DoubleWriteBuffer {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("DoubleWriteBuffer")
53            .field("path", &self.path)
54            .field("mode", &self.mode)
55            .field("write_pos", &self.write_pos)
56            .field("count", &self.count)
57            .finish()
58    }
59}
60
61impl DoubleWriteBuffer {
62    /// Open or create the double-write buffer file in the requested I/O mode.
63    ///
64    /// Callers that want the DWB disabled must not call this at all;
65    /// `DwbMode::Off` returns [`WalError::DwbOffNotOpenable`].
66    pub fn open(path: &Path, mode: DwbMode) -> Result<Self> {
67        if mode == DwbMode::Off {
68            return Err(WalError::DwbOffNotOpenable);
69        }
70
71        let mut opts = OpenOptions::new();
72        opts.read(true).write(true).create(true).truncate(false);
73        #[cfg(target_os = "linux")]
74        if mode == DwbMode::Direct {
75            opts.custom_flags(libc::O_DIRECT);
76        }
77
78        let file = opts.open(path).map_err(|e| {
79            tracing::warn!(path = %path.display(), error = %e, mode = ?mode, "failed to open double-write buffer");
80            WalError::Io(e)
81        })?;
82
83        let header_buf = if mode == DwbMode::Direct {
84            Some(AlignedBuf::new(DWB_HEADER_STRIDE, DEFAULT_ALIGNMENT)?)
85        } else {
86            None
87        };
88
89        let mut dwb = Self {
90            file,
91            path: path.to_path_buf(),
92            mode,
93            write_pos: 0,
94            count: 0,
95            next_seq: None,
96            dirty: false,
97            stage: None,
98            slots_written: 0,
99            header_buf,
100        };
101
102        // Try to read existing header (first DWB_HEADER_FIELDS bytes of block 0).
103        let file_len = dwb.file.metadata().map(|m| m.len()).unwrap_or(0);
104        if file_len >= DWB_HEADER_STRIDE as u64 {
105            let mut block = vec![0u8; DWB_HEADER_STRIDE];
106            dwb.file.seek(SeekFrom::Start(0)).map_err(WalError::Io)?;
107            if dwb.file.read_exact(&mut block).is_ok() {
108                let mut arr4 = [0u8; 4];
109                arr4.copy_from_slice(&block[0..4]);
110                let magic = u32::from_le_bytes(arr4);
111                if magic == DWB_MAGIC {
112                    arr4.copy_from_slice(&block[4..8]);
113                    dwb.count = u32::from_le_bytes(arr4);
114                    arr4.copy_from_slice(&block[8..12]);
115                    dwb.write_pos = u32::from_le_bytes(arr4);
116                }
117            }
118        }
119
120        Ok(dwb)
121    }
122
123    /// I/O mode this buffer was opened with.
124    pub fn mode(&self) -> DwbMode {
125        self.mode
126    }
127
128    /// Path to the double-write buffer file.
129    pub fn path(&self) -> &Path {
130        &self.path
131    }
132
133    /// Write a WAL record to the double-write buffer before WAL append.
134    ///
135    /// The record is written at the current circular position and the file
136    /// is fsynced immediately. Use `write_record_deferred` + `flush` for
137    /// batch mode (multiple records per fsync).
138    pub fn write_record(&mut self, record: &WalRecord) -> Result<DwbMirror> {
139        let mirrored = self.write_record_deferred(record)?;
140        self.flush()?;
141        Ok(mirrored)
142    }
143
144    /// Stage a WAL record in its ring slot without any disk write.
145    ///
146    /// The slot image stays in memory until `flush()` writes it and fsyncs
147    /// the file. Use this in batch mode: stage all records in a group commit
148    /// batch, then call `flush()` once. A flush writes at most
149    /// `DWB_CAPACITY` slots, however many records it covers.
150    ///
151    /// Returns [`DwbMirror::Skipped`] for a record that cannot be mirrored, so
152    /// the caller can account for the lost protection instead of assuming the
153    /// record is covered.
154    pub fn write_record_deferred(&mut self, record: &WalRecord) -> Result<DwbMirror> {
155        // Crash injection: a DWB slot write that fails on a healthy WAL. The
156        // append it belongs to must still succeed, degraded but observable.
157        nodedb_types::fail_point_err!("wal::dwb_write_failure", |detail: String| WalError::Io(
158            std::io::Error::other(format!("failpoint wal::dwb_write_failure: {detail}"))
159        ));
160
161        let total_size = HEADER_SIZE + record.payload.len();
162
163        // A record spanning several slots could not be validated by a single
164        // slot's CRC, which is what recovery relies on. Report the gap rather
165        // than leaving the caller believing the record is protected.
166        if total_size > DWB_SLOT_RECORD_MAX {
167            return Ok(DwbMirror::Skipped(DwbSkipReason::RecordTooLarge {
168                size: total_size,
169                max: DWB_SLOT_RECORD_MAX,
170            }));
171        }
172
173        if self.mode == DwbMode::Off {
174            return Err(WalError::DwbOffNotOpenable);
175        }
176
177        let seq = self.take_slot_seq()?;
178        let prefix = SlotPrefix { seq, total_size }.encode();
179        let header_bytes = record.header.to_bytes();
180
181        let stage = match self.stage.take() {
182            Some(stage) => stage,
183            None => StagedRing::new()?,
184        };
185        let stage = self.stage.insert(stage);
186        let image = stage.cleared_image(self.write_pos);
187        image.write(&prefix);
188        image.write(&header_bytes);
189        image.write(&record.payload);
190        stage.mark_staged();
191
192        self.write_pos = self.write_pos.wrapping_add(1);
193        self.count = self.count.saturating_add(1).min(DWB_CAPACITY as u32);
194        self.dirty = true;
195
196        Ok(DwbMirror::Mirrored)
197    }
198
199    /// Records staged since the last flush whose slots will reach disk at
200    /// the next flush. At most `DWB_CAPACITY`.
201    pub fn staged_slots(&self) -> u32 {
202        self.stage.as_ref().map_or(0, StagedRing::surviving)
203    }
204
205    /// Slots this buffer wrote to disk since it was opened.
206    pub fn slots_written(&self) -> u64 {
207        self.slots_written
208    }
209
210    /// Write the staged slots that survive in the ring, oldest first, with
211    /// no fsync. Returns the number of slots written.
212    ///
213    /// `flush()` calls this before writing the header. A caller that must
214    /// tell a failed slot write from a failed fsync calls it first.
215    pub fn write_staged(&mut self) -> Result<u32> {
216        let Some(stage) = self.stage.as_mut() else {
217            return Ok(0);
218        };
219        let surviving = stage.surviving();
220        let first = self.write_pos.wrapping_sub(surviving);
221        for step in 0..surviving {
222            let idx = first.wrapping_add(step);
223            let offset = slot_offset(idx);
224            let image = stage.image_mut(idx);
225            let bytes = match self.mode {
226                DwbMode::Off => return Err(WalError::DwbOffNotOpenable),
227                DwbMode::Buffered => image.as_slice(),
228                DwbMode::Direct => {
229                    // Zero the tail so the full aligned slot does not leak an
230                    // earlier image's bytes. Only surviving slots pay for it.
231                    zero_tail(image);
232                    let slice = full_capacity_slice(image);
233                    debug_assert_eq!(slice.len(), DWB_SLOT_STRIDE);
234                    debug_assert!(is_aligned(offset as usize, DEFAULT_ALIGNMENT));
235                    slice
236                }
237            };
238            pwrite_all(&self.file, bytes, offset)?;
239            metrics::add_bytes_written(bytes.len() as u64);
240            self.slots_written = self.slots_written.saturating_add(1);
241        }
242        stage.clear();
243        Ok(surviving)
244    }
245
246    /// Write the staged slots and the DWB header, then fsync the file.
247    ///
248    /// Must be called after one or more `write_record_deferred` calls to make
249    /// the records durable. The single fsync covers all deferred writes since
250    /// the last flush — amortizing the cost across the group commit batch.
251    pub fn flush(&mut self) -> Result<()> {
252        if !self.dirty {
253            return Ok(());
254        }
255
256        self.write_staged()?;
257
258        let mut header = [0u8; DWB_HEADER_FIELDS];
259        header[0..4].copy_from_slice(&DWB_MAGIC.to_le_bytes());
260        header[4..8].copy_from_slice(&self.count.to_le_bytes());
261        header[8..12].copy_from_slice(&self.write_pos.to_le_bytes());
262
263        match self.mode {
264            DwbMode::Off => return Err(WalError::DwbOffNotOpenable),
265            DwbMode::Buffered => {
266                self.file.seek(SeekFrom::Start(0)).map_err(WalError::Io)?;
267                self.file.write_all(&header).map_err(WalError::Io)?;
268                metrics::add_bytes_written(header.len() as u64);
269            }
270            DwbMode::Direct => {
271                let Some(buf) = self.header_buf.as_mut() else {
272                    return Err(WalError::AlignmentViolation {
273                        context: "DWB Direct mode without an aligned header buffer",
274                        required: DWB_HEADER_STRIDE,
275                        actual: 0,
276                    });
277                };
278                buf.clear();
279                buf.write(&header);
280                zero_tail(buf);
281                let slice = full_capacity_slice(buf);
282                debug_assert_eq!(slice.len(), DWB_HEADER_STRIDE);
283                pwrite_all(&self.file, slice, 0)?;
284                metrics::add_bytes_written(slice.len() as u64);
285            }
286        }
287
288        self.file.sync_all().map_err(WalError::Io)?;
289        self.dirty = false;
290
291        Ok(())
292    }
293
294    /// Sequence number for the slot about to be written.
295    ///
296    /// The first call outranks every sequence number still readable in the
297    /// ring. A crash can leave the file header behind the slots it describes,
298    /// so the slots themselves — not the header — decide where the sequence
299    /// resumes; otherwise a reused number would let a stale copy tie with the
300    /// record that replaced it.
301    fn take_slot_seq(&mut self) -> Result<u64> {
302        let seq = match self.next_seq {
303            Some(seq) => seq,
304            None => super::recover::scan_max_seq(self)?.saturating_add(1),
305        };
306        self.next_seq = Some(seq.saturating_add(1));
307        Ok(seq)
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::double_write::metrics::wal_dwb_bytes_written_total;
315    use crate::record::{RecordType, WalRecordArgs};
316
317    fn open_buffered(path: &Path) -> DoubleWriteBuffer {
318        DoubleWriteBuffer::open(path, DwbMode::Buffered).unwrap()
319    }
320
321    fn record(lsn: u64, payload: &[u8]) -> WalRecord {
322        WalRecord::new(WalRecordArgs {
323            record_type: RecordType::Put as u32,
324            lsn,
325            tenant_id: 1,
326            vshard_id: 0,
327            database_id: 0,
328            payload: payload.to_vec(),
329            encryption_key: None,
330            preamble_bytes: None,
331        })
332        .unwrap()
333    }
334
335    fn mirror(dwb: &mut DoubleWriteBuffer, rec: &WalRecord) {
336        assert_eq!(dwb.write_record(rec).unwrap(), DwbMirror::Mirrored);
337    }
338
339    fn mirror_deferred(dwb: &mut DoubleWriteBuffer, rec: &WalRecord) {
340        assert_eq!(dwb.write_record_deferred(rec).unwrap(), DwbMirror::Mirrored);
341    }
342
343    #[test]
344    fn write_and_recover() {
345        let dir = tempfile::tempdir().unwrap();
346        let mut dwb = open_buffered(&dir.path().join("test.dwb"));
347
348        mirror(&mut dwb, &record(42, b"hello double-write"));
349
350        let rec = dwb.recover_record(42).unwrap().expect("recoverable");
351        assert_eq!(rec.header.lsn, 42);
352        assert_eq!(rec.payload, b"hello double-write");
353    }
354
355    #[test]
356    fn recover_nonexistent_returns_none() {
357        let dir = tempfile::tempdir().unwrap();
358        let mut dwb = open_buffered(&dir.path().join("test2.dwb"));
359        assert!(dwb.recover_record(999).unwrap().is_none());
360    }
361
362    #[test]
363    fn survives_reopen() {
364        let dir = tempfile::tempdir().unwrap();
365        let path = dir.path().join("reopen.dwb");
366
367        {
368            let mut dwb = open_buffered(&path);
369            mirror(&mut dwb, &record(7, b"durable"));
370        }
371
372        let mut dwb = open_buffered(&path);
373        let recovered = dwb.recover_record(7).unwrap().expect("recoverable");
374        assert_eq!(recovered.payload, b"durable");
375    }
376
377    #[test]
378    fn batch_deferred_writes_and_flush() {
379        let dir = tempfile::tempdir().unwrap();
380        let mut dwb = open_buffered(&dir.path().join("batch.dwb"));
381
382        for lsn in 1..=5u64 {
383            mirror_deferred(&mut dwb, &record(lsn, format!("batch-{lsn}").as_bytes()));
384        }
385
386        assert!(dwb.dirty);
387        dwb.flush().unwrap();
388        assert!(!dwb.dirty);
389
390        for lsn in 1..=5u64 {
391            let recovered = dwb.recover_record(lsn).unwrap().expect("recoverable");
392            assert_eq!(recovered.payload, format!("batch-{lsn}").into_bytes());
393        }
394    }
395
396    #[test]
397    fn flush_is_idempotent() {
398        let dir = tempfile::tempdir().unwrap();
399        let mut dwb = open_buffered(&dir.path().join("idem.dwb"));
400
401        dwb.flush().unwrap();
402        assert!(!dwb.dirty);
403
404        mirror_deferred(&mut dwb, &record(1, b"data"));
405        dwb.flush().unwrap();
406        dwb.flush().unwrap();
407        assert!(!dwb.dirty);
408    }
409
410    #[test]
411    fn oversized_record_is_reported_as_skipped() {
412        let dir = tempfile::tempdir().unwrap();
413        let mut dwb = open_buffered(&dir.path().join("oversized.dwb"));
414
415        let payload = vec![0xabu8; DWB_SLOT_RECORD_MAX];
416        let outcome = dwb.write_record_deferred(&record(11, &payload)).unwrap();
417
418        assert_eq!(
419            outcome,
420            DwbMirror::Skipped(DwbSkipReason::RecordTooLarge {
421                size: HEADER_SIZE + payload.len(),
422                max: DWB_SLOT_RECORD_MAX,
423            })
424        );
425        assert!(dwb.recover_record(11).unwrap().is_none());
426    }
427
428    /// More records than the ring holds within one flush write exactly one
429    /// ring of slots, and the ring keeps the newest records.
430    #[test]
431    fn flush_writes_at_most_one_ring_of_slots() {
432        let dir = tempfile::tempdir().unwrap();
433        let path = dir.path().join("ring.dwb");
434        let mut dwb = open_buffered(&path);
435
436        let total = DWB_CAPACITY as u64 * 3 + 7;
437        for lsn in 1..=total {
438            mirror_deferred(&mut dwb, &record(lsn, format!("rec-{lsn}").as_bytes()));
439        }
440        assert_eq!(dwb.slots_written(), 0, "staging writes nothing to disk");
441        assert_eq!(dwb.staged_slots(), DWB_CAPACITY as u32);
442        dwb.flush().unwrap();
443        assert_eq!(dwb.slots_written(), DWB_CAPACITY as u64);
444        assert_eq!(dwb.staged_slots(), 0);
445
446        let mut reopened = open_buffered(&path);
447        let newest_lost = total - DWB_CAPACITY as u64;
448        for lsn in (newest_lost + 1)..=total {
449            let recovered = reopened.recover_record(lsn).unwrap().expect("recoverable");
450            assert_eq!(recovered.payload, format!("rec-{lsn}").into_bytes());
451        }
452        assert!(reopened.recover_record(newest_lost).unwrap().is_none());
453    }
454
455    #[test]
456    fn flush_writes_one_slot_per_record_below_capacity() {
457        let dir = tempfile::tempdir().unwrap();
458        let mut dwb = open_buffered(&dir.path().join("few.dwb"));
459
460        for lsn in 1..=10u64 {
461            mirror_deferred(&mut dwb, &record(lsn, b"few"));
462        }
463        dwb.flush().unwrap();
464        assert_eq!(dwb.slots_written(), 10);
465
466        mirror_deferred(&mut dwb, &record(11, b"next batch"));
467        dwb.flush().unwrap();
468        assert_eq!(dwb.slots_written(), 11);
469        assert!(dwb.recover_record(11).unwrap().is_some());
470    }
471
472    /// A staged record is not on disk until the flush, and is recoverable
473    /// from the file once the flush returns.
474    #[test]
475    fn staged_record_is_recoverable_only_after_flush() {
476        let dir = tempfile::tempdir().unwrap();
477        let path = dir.path().join("staged.dwb");
478        let mut dwb = open_buffered(&path);
479
480        mirror_deferred(&mut dwb, &record(21, b"torn in the wal"));
481        assert!(open_buffered(&path).recover_record(21).unwrap().is_none());
482
483        dwb.flush().unwrap();
484        let recovered = open_buffered(&path)
485            .recover_record(21)
486            .unwrap()
487            .expect("recoverable after flush");
488        assert_eq!(recovered.payload, b"torn in the wal");
489    }
490
491    #[test]
492    fn bytes_written_counter_increments() {
493        let dir = tempfile::tempdir().unwrap();
494        let before = wal_dwb_bytes_written_total();
495
496        let mut dwb = open_buffered(&dir.path().join("counter.dwb"));
497        mirror(&mut dwb, &record(1, b"counted"));
498
499        assert!(wal_dwb_bytes_written_total() > before);
500    }
501}