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}