Skip to main content

nodedb_wal/record/
padding.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Alignment padding records.
4//!
5//! O_DIRECT requires every write to be a multiple of the device block size, so
6//! the tail of a flush batch is padded out to the next boundary and the file
7//! offset advances by the padded length. The bytes in that gap are not part of
8//! any record.
9//!
10//! Leaving the gap unframed breaks replay. A reader walks records back to back,
11//! so it lands on the padding and tries to parse it as a record header — the
12//! bytes are stale content from a previous batch (the write buffer is reused
13//! without being re-zeroed), and even all-zero padding fails the magic check.
14//! The reader treats that as the end of the committed prefix, so a segment
15//! holding more than one flush batch replays only its first batch.
16//!
17//! Padding is therefore framed as a record: the gap is filled with a `Noop`
18//! whose payload is zeroes, keeping the record chain continuous across batch
19//! boundaries so every reader walks the whole segment. Padding records carry
20//! LSN 0, are never mirrored into the double-write buffer, and are skipped by
21//! all readers rather than surfaced to replay.
22
23use super::header::HEADER_SIZE;
24use super::types::RecordType;
25use super::wal_record::{WalRecord, WalRecordArgs};
26use crate::align::{AlignedBuf, round_up};
27use crate::error::{Result, WalError};
28
29/// Smallest on-disk footprint a padding record can occupy: a bare header with
30/// an empty payload. A gap narrower than this cannot be framed at all.
31pub const MIN_PADDING_RECORD_SIZE: usize = HEADER_SIZE;
32
33/// On-disk size of the padding record needed to bring `len` up to an
34/// `alignment` boundary, or `None` when `len` is already aligned.
35///
36/// A gap narrower than a record header cannot hold one, so the span is
37/// extended by a whole extra block. The result is always either `0` or at
38/// least `MIN_PADDING_RECORD_SIZE`, and `len + span` is always aligned.
39pub fn padding_span(len: usize, alignment: usize) -> Option<usize> {
40    let gap = round_up(len, alignment) - len;
41    if gap == 0 {
42        return None;
43    }
44    if gap < MIN_PADDING_RECORD_SIZE {
45        Some(gap + alignment)
46    } else {
47        Some(gap)
48    }
49}
50
51/// Build a padding record occupying exactly `total_size` bytes on disk.
52///
53/// Padding is never encrypted: it carries no data, and the AES-GCM tag would
54/// change its on-disk length, which is the one property that has to be exact.
55pub fn padding_record(total_size: usize) -> Result<WalRecord> {
56    let payload_len = total_size
57        .checked_sub(HEADER_SIZE)
58        .ok_or(WalError::AlignmentViolation {
59            context: "alignment padding is narrower than a WAL record header",
60            required: HEADER_SIZE,
61            actual: total_size,
62        })?;
63
64    WalRecord::new(WalRecordArgs {
65        record_type: RecordType::Noop as u32,
66        lsn: 0,
67        tenant_id: 0,
68        vshard_id: 0,
69        database_id: 0,
70        payload: vec![0u8; payload_len],
71        encryption_key: None,
72        preamble_bytes: None,
73    })
74}
75
76/// Fill the tail of `buffer` with a framed padding record so it ends on an
77/// `alignment` boundary. Shared by `WalWriter::flush_buffer` and
78/// `UringWriter::submit_and_wait_write`, whose O_DIRECT batches both need the
79/// same framing before submission — see the module doc for why the gap can't
80/// be left unframed.
81///
82/// `context` is threaded into [`WalError::AlignmentViolation`] so a caller
83/// can identify which writer's buffer ran out of room.
84pub(crate) fn pad_buffer_to_alignment(
85    buffer: &mut AlignedBuf,
86    alignment: usize,
87    context: &'static str,
88) -> Result<()> {
89    let Some(span) = padding_span(buffer.len(), alignment) else {
90        return Ok(());
91    };
92    if buffer.remaining() < span {
93        return Err(WalError::AlignmentViolation {
94            context,
95            required: span,
96            actual: buffer.remaining(),
97        });
98    }
99    let padding = padding_record(span)?;
100    buffer.write(&padding.header.to_bytes());
101    buffer.write(&padding.payload);
102    Ok(())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::align::DEFAULT_ALIGNMENT;
109
110    #[test]
111    fn aligned_length_needs_no_padding() {
112        assert_eq!(padding_span(0, DEFAULT_ALIGNMENT), None);
113        assert_eq!(padding_span(DEFAULT_ALIGNMENT, DEFAULT_ALIGNMENT), None);
114        assert_eq!(padding_span(2 * DEFAULT_ALIGNMENT, DEFAULT_ALIGNMENT), None);
115    }
116
117    #[test]
118    fn span_closes_the_gap_to_the_next_boundary() {
119        let span = padding_span(100, DEFAULT_ALIGNMENT).unwrap();
120        assert_eq!(span, DEFAULT_ALIGNMENT - 100);
121        assert_eq!((100 + span) % DEFAULT_ALIGNMENT, 0);
122    }
123
124    #[test]
125    fn narrow_gap_is_extended_by_a_whole_block() {
126        // One byte short of the boundary: a header does not fit, so the span
127        // must borrow an entire extra block and still land on a boundary.
128        let len = DEFAULT_ALIGNMENT - 1;
129        let span = padding_span(len, DEFAULT_ALIGNMENT).unwrap();
130        assert!(span >= MIN_PADDING_RECORD_SIZE);
131        assert_eq!((len + span) % DEFAULT_ALIGNMENT, 0);
132    }
133
134    #[test]
135    fn every_unaligned_length_yields_a_framable_aligned_span() {
136        for len in 0..(2 * DEFAULT_ALIGNMENT) {
137            match padding_span(len, DEFAULT_ALIGNMENT) {
138                None => assert_eq!(len % DEFAULT_ALIGNMENT, 0),
139                Some(span) => {
140                    assert!(
141                        span >= MIN_PADDING_RECORD_SIZE,
142                        "span {span} cannot be framed"
143                    );
144                    assert_eq!((len + span) % DEFAULT_ALIGNMENT, 0);
145                    assert!(padding_record(span).is_ok());
146                }
147            }
148        }
149    }
150
151    #[test]
152    fn padding_record_occupies_exactly_its_span() {
153        let record = padding_record(512).unwrap();
154        assert_eq!(HEADER_SIZE + record.payload.len(), 512);
155        assert_eq!(record.header.lsn, 0);
156        assert_eq!(record.header.record_type, RecordType::Noop as u32);
157        assert!(record.verify_checksum().is_ok());
158    }
159
160    #[test]
161    fn span_below_a_header_is_rejected() {
162        assert!(matches!(
163            padding_record(HEADER_SIZE - 1),
164            Err(WalError::AlignmentViolation { .. })
165        ));
166    }
167}