Skip to main content

nodedb_wal/double_write/
layout.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! On-disk layout of the double-write buffer file.
4//!
5//! ```text
6//! offset 0                       one aligned block: file header
7//!   [magic:4][count:4][write_pos:4][zero padding to DWB_HEADER_STRIDE]
8//!
9//! offset DWB_HEADER_STRIDE + i * DWB_SLOT_STRIDE      slot i
10//!   [slot_magic:4][seq:8][total_size:4][prefix_crc:4][record header][payload]
11//! ```
12//!
13//! ## Slot sequence numbers
14//!
15//! The ring is fixed-size, so one LSN can legitimately occupy two slots: a
16//! crash between the DWB fsync and the WAL fsync leaves an LSN durable in the
17//! DWB but absent from the WAL, recovery resumes at that LSN, and the next
18//! record written under it lands in a different slot. Both copies pass CRC, so
19//! nothing in the record itself says which one the WAL actually committed.
20//! `seq` breaks that tie: it increases with every slot write, so the highest
21//! `seq` is always the copy written last. Without it, recovery can hand back a
22//! payload that was never acknowledged to any client.
23//!
24//! ## Format change
25//!
26//! Slots begin with `slot_magic`, not a bare `[total_size:4]`. Its value is
27//! far outside the range a `total_size` could ever hold, so a slot written
28//! by an older build that used the bare format fails the prefix check and
29//! is treated as unusable rather than being misread as a sequence-numbered
30//! slot. A DWB only ever holds records from the tail of the log — the worst
31//! case is that torn-write recovery falls back to "no copy available" for
32//! records written before the upgrade.
33
34use crate::align::DEFAULT_ALIGNMENT;
35use crate::record::HEADER_SIZE;
36
37/// Maximum number of records kept in the double-write buffer.
38/// Only the most recent records matter — torn writes affect the tail.
39///
40/// This is a compile-time constant used in slot offset arithmetic. It cannot
41/// be made runtime-configurable without storing capacity in the struct and
42/// adjusting all offset calculations accordingly. The value matches the
43/// `WalTuning::dwb_capacity` default (64).
44pub(crate) const DWB_CAPACITY: usize = 64;
45
46/// Largest record (header + payload) that fits in one slot.
47pub(crate) const DWB_SLOT_RECORD_MAX: usize = 64 * 1024;
48
49/// Identifies a sequence-numbered slot. Deliberately larger than any value a
50/// pre-sequence-number slot could carry in its leading `total_size` field.
51const DWB_SLOT_MAGIC: u32 = 0x4457_5342; // "DWSB"
52
53/// Bytes of slot metadata ahead of the WAL record header.
54pub(crate) const SLOT_PREFIX_SIZE: usize = 20;
55
56/// Raw slot content size: [prefix][header][payload-up-to-64KiB].
57const DWB_SLOT_RAW: usize = SLOT_PREFIX_SIZE + HEADER_SIZE + DWB_SLOT_RECORD_MAX;
58
59/// Per-slot on-disk stride, padded up to the O_DIRECT block size so every
60/// slot offset is block-aligned.
61pub(crate) const DWB_SLOT_STRIDE: usize = round_up_const(DWB_SLOT_RAW, DEFAULT_ALIGNMENT);
62
63/// On-disk header occupies one aligned block (not the raw 12 bytes) so the
64/// first slot starts at a block-aligned offset. The first 12 bytes of the
65/// block carry the header fields; the remainder is zero-padded.
66pub(crate) const DWB_HEADER_STRIDE: usize = DEFAULT_ALIGNMENT;
67pub(crate) const DWB_HEADER_FIELDS: usize = 12;
68pub(crate) const DWB_MAGIC: u32 = 0x4457_4246; // "DWBF"
69
70pub(crate) const fn round_up_const(value: usize, align: usize) -> usize {
71    (value + align - 1) & !(align - 1)
72}
73
74/// Slot stride in bytes. Exposed for tests and for callers that want to
75/// size DWB files ahead of time.
76pub const fn slot_stride() -> usize {
77    DWB_SLOT_STRIDE
78}
79
80/// Largest record — WAL header plus payload — the buffer can mirror. Records
81/// above this size are appended to the WAL without torn-write protection.
82pub const fn slot_record_max() -> usize {
83    DWB_SLOT_RECORD_MAX
84}
85
86/// Byte offset of slot `idx` within the DWB file.
87pub(crate) fn slot_offset(idx: u32) -> u64 {
88    DWB_HEADER_STRIDE as u64 + (idx as u64 % DWB_CAPACITY as u64) * DWB_SLOT_STRIDE as u64
89}
90
91/// The self-describing metadata at the head of a slot.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(crate) struct SlotPrefix {
94    /// Monotonic write ordering across the whole ring.
95    pub seq: u64,
96    /// WAL header bytes plus payload bytes stored in this slot.
97    pub total_size: usize,
98}
99
100impl SlotPrefix {
101    pub(crate) fn encode(&self) -> [u8; SLOT_PREFIX_SIZE] {
102        let mut buf = [0u8; SLOT_PREFIX_SIZE];
103        buf[0..4].copy_from_slice(&DWB_SLOT_MAGIC.to_le_bytes());
104        buf[4..12].copy_from_slice(&self.seq.to_le_bytes());
105        buf[12..16].copy_from_slice(&(self.total_size as u32).to_le_bytes());
106        let crc = crc32c::crc32c(&buf[0..16]);
107        buf[16..20].copy_from_slice(&crc.to_le_bytes());
108        buf
109    }
110
111    /// Parse a slot prefix, returning `None` for anything that is not a
112    /// well-formed, checksummed prefix of the current format.
113    ///
114    /// The prefix carries its own CRC because `seq` decides which of two
115    /// same-LSN copies wins, and the record CRC does not cover it — a slot
116    /// whose head was torn must lose the tie rather than win it with a
117    /// garbage sequence number.
118    pub(crate) fn decode(bytes: &[u8]) -> Option<Self> {
119        if bytes.len() < SLOT_PREFIX_SIZE {
120            return None;
121        }
122        let mut arr4 = [0u8; 4];
123        arr4.copy_from_slice(&bytes[0..4]);
124        if u32::from_le_bytes(arr4) != DWB_SLOT_MAGIC {
125            return None;
126        }
127        arr4.copy_from_slice(&bytes[16..20]);
128        if u32::from_le_bytes(arr4) != crc32c::crc32c(&bytes[0..16]) {
129            return None;
130        }
131        let mut arr8 = [0u8; 8];
132        arr8.copy_from_slice(&bytes[4..12]);
133        let seq = u64::from_le_bytes(arr8);
134        arr4.copy_from_slice(&bytes[12..16]);
135        let total_size = u32::from_le_bytes(arr4) as usize;
136        if !(HEADER_SIZE..=DWB_SLOT_RECORD_MAX).contains(&total_size) {
137            return None;
138        }
139        Some(Self { seq, total_size })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::align::is_aligned;
147
148    #[test]
149    fn slot_stride_is_o_direct_aligned() {
150        // The DWB slot stride must be a multiple of the WAL alignment
151        // (4 KiB) so the file can be opened with O_DIRECT alongside an
152        // O_DIRECT WAL. With a non-aligned stride, every slot after the
153        // first lands at an unaligned offset and the kernel rejects the
154        // write with -EINVAL.
155        assert!(
156            is_aligned(DWB_SLOT_STRIDE, DEFAULT_ALIGNMENT),
157            "DWB slot stride {DWB_SLOT_STRIDE} bytes is not a multiple of {DEFAULT_ALIGNMENT}"
158        );
159        assert!(is_aligned(DWB_HEADER_STRIDE, DEFAULT_ALIGNMENT));
160        for i in 0..DWB_CAPACITY as u32 {
161            assert!(is_aligned(slot_offset(i) as usize, DEFAULT_ALIGNMENT));
162        }
163    }
164
165    #[test]
166    fn prefix_round_trips() {
167        let prefix = SlotPrefix {
168            seq: 0x0102_0304_0506_0708,
169            total_size: HEADER_SIZE + 11,
170        };
171        assert_eq!(SlotPrefix::decode(&prefix.encode()), Some(prefix));
172    }
173
174    #[test]
175    fn torn_prefix_is_rejected() {
176        let prefix = SlotPrefix {
177            seq: 9,
178            total_size: HEADER_SIZE,
179        };
180        let mut bytes = prefix.encode();
181        bytes[6] ^= 0xff;
182        assert_eq!(SlotPrefix::decode(&bytes), None);
183    }
184
185    #[test]
186    fn pre_sequence_number_slot_fails_closed() {
187        // An older build wrote `[total_size:4]` where the magic now lives.
188        let mut bytes = [0u8; SLOT_PREFIX_SIZE];
189        bytes[0..4].copy_from_slice(&((HEADER_SIZE + 32) as u32).to_le_bytes());
190        assert_eq!(SlotPrefix::decode(&bytes), None);
191    }
192}