Skip to main content

nodedb_wal/writer/
core.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::fs::File;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use super::open::open_segment_file;
8use crate::align::AlignedBuf;
9use crate::double_write::{DoubleWriteBuffer, DwbProtection};
10use crate::error::{Result, WalError};
11use crate::preamble::SegmentPreamble;
12use crate::record::{HEADER_SIZE, MIN_PADDING_RECORD_SIZE, RecordTarget, WalRecord, WalRecordArgs};
13
14use super::config::{WalWriterConfig, open_dwb_for, resume_offset};
15use super::durability::{DurabilityState, fsync_and_track};
16
17/// Append-only WAL writer.
18pub struct WalWriter {
19    /// The WAL file handle (opened with O_DIRECT if configured).
20    pub(super) file: File,
21
22    /// Aligned write buffer for batching records before flush.
23    pub(super) buffer: AlignedBuf,
24
25    /// Current file write offset (always aligned).
26    pub(super) file_offset: u64,
27
28    /// Next LSN to assign.
29    next_lsn: AtomicU64,
30
31    /// Whether the writer has been sealed (no more writes accepted).
32    sealed: bool,
33
34    /// Standing of the file relative to the last successful fsync. The write
35    /// buffer is cleared by the flush, so it cannot answer "is an fsync still
36    /// owed?" on its own.
37    pub(super) durability: DurabilityState,
38
39    /// Configuration.
40    pub(super) config: WalWriterConfig,
41
42    /// Optional key ring for payload encryption (supports key rotation).
43    encryption_ring: Option<crate::crypto::KeyRing>,
44
45    /// Preamble written at the start of this segment (when encryption is active).
46    /// Its 16 bytes are included as part of the AAD on every encrypted record,
47    /// binding ciphertext to the segment it was written in.
48    segment_preamble: Option<SegmentPreamble>,
49
50    /// Optional double-write buffer for torn write protection.
51    /// Records are written here before the WAL for crash recovery.
52    pub(super) double_write: Option<DoubleWriteBuffer>,
53
54    /// Where the double-write buffer lives, so a degraded writer can be
55    /// reattached without reopening the segment. `None` when the DWB is
56    /// configured off.
57    pub(super) dwb_path: Option<PathBuf>,
58
59    /// Whether torn-write protection is actually in force. A DWB failure never
60    /// fails the WAL write, so this is the only thing that tells a caller the
61    /// log has stopped being recoverable from a torn tail.
62    pub(super) dwb_protection: DwbProtection,
63
64    /// Records appended to this segment with no double-write copy behind them.
65    pub(super) dwb_unprotected_records: u64,
66
67    /// A record was appended after the last time anchor.
68    pub(super) unanchored: bool,
69
70    /// `(lsn, hlc_wall_ns)` of an anchor in the batch whose fsync has not
71    /// succeeded yet.
72    pub(super) pending_anchor: Option<(u64, u64)>,
73}
74
75impl WalWriter {
76    /// Open or create a WAL file at the given path.
77    pub fn open(path: &Path, config: WalWriterConfig) -> Result<Self> {
78        let file = open_segment_file(path, config.use_direct_io)?;
79
80        let buffer = AlignedBuf::new(config.write_buffer_size, config.alignment)?;
81
82        // Scan existing WAL for recovery if the file has data.
83        let (file_offset, next_lsn) = if path.exists() && std::fs::metadata(path)?.len() > 0 {
84            let info = crate::recovery::recover(path)?;
85            (
86                resume_offset(
87                    info.end_offset,
88                    config.use_direct_io,
89                    config.alignment,
90                    path,
91                ),
92                info.next_lsn(),
93            )
94        } else {
95            (0, 1)
96        };
97
98        let dwb = open_dwb_for(&config, path);
99
100        Ok(Self {
101            file,
102            buffer,
103            file_offset,
104            next_lsn: AtomicU64::new(next_lsn),
105            sealed: false,
106            durability: DurabilityState::new(),
107            config,
108            encryption_ring: None,
109            segment_preamble: None,
110            double_write: dwb.buffer,
111            dwb_path: dwb.path,
112            dwb_protection: dwb.protection,
113            dwb_unprotected_records: 0,
114            unanchored: false,
115            pending_anchor: None,
116        })
117    }
118
119    /// Resume appending to an existing segment whose filename declares
120    /// `declared_first_lsn` as the lowest LSN it may hold.
121    ///
122    /// [`Self::open`] derives the next LSN from the records it finds, and a
123    /// segment that holds none — created by a rollover that was never written
124    /// to, or one whose every record was torn away — would restart the
125    /// sequence at 1. Those LSNs are already spoken for by the earlier
126    /// segments, so replay would see duplicates and truncation would delete
127    /// the originals. The filename is the authority on where this segment's
128    /// range begins, so the resumed sequence never drops below it.
129    pub fn open_resuming(
130        path: &Path,
131        config: WalWriterConfig,
132        declared_first_lsn: u64,
133    ) -> Result<Self> {
134        let writer = Self::open(path, config)?;
135        let resumed = writer.next_lsn().max(declared_first_lsn);
136        writer.next_lsn.store(resumed, Ordering::Relaxed);
137        Ok(writer)
138    }
139
140    pub(crate) fn can_set_encryption_ring(&self) -> bool {
141        self.file_offset == 0 && self.buffer.is_empty()
142    }
143
144    /// Set the encryption key. When set, all subsequent records will have
145    /// their payloads encrypted with AES-256-GCM.
146    ///
147    /// Writes the 16-byte WAL segment preamble at the current file offset.
148    /// Must be called before the first `append`. Calling it after records
149    /// have already been written to this file returns an error.
150    pub fn set_encryption_key(&mut self, key: crate::crypto::WalEncryptionKey) -> Result<()> {
151        self.set_encryption_ring(crate::crypto::KeyRing::new(key))
152    }
153
154    /// Set the key ring directly (for key rotation with dual-key reads).
155    ///
156    /// Writes the 16-byte WAL segment preamble at the current file offset.
157    /// Must be called before the first `append`. Calling it after records
158    /// have already been written to this file returns an error.
159    pub fn set_encryption_ring(&mut self, ring: crate::crypto::KeyRing) -> Result<()> {
160        if self.file_offset != 0 || !self.buffer.is_empty() {
161            return Err(WalError::EncryptionError {
162                detail: "set_encryption_ring must be called before writing any records".into(),
163            });
164        }
165        let epoch = *ring.current().epoch();
166        let preamble = SegmentPreamble::new_wal(epoch);
167        let preamble_bytes = preamble.to_bytes();
168
169        // Write preamble into the buffer so it gets flushed with the first
170        // record batch (or on the next sync).
171        self.buffer.write(&preamble_bytes);
172
173        self.encryption_ring = Some(ring);
174        self.segment_preamble = Some(preamble);
175        Ok(())
176    }
177
178    /// Access the key ring (for decryption during replay).
179    pub fn encryption_ring(&self) -> Option<&crate::crypto::KeyRing> {
180        self.encryption_ring.as_ref()
181    }
182
183    /// The preamble for this segment, if encryption was enabled.
184    pub fn segment_preamble(&self) -> Option<&SegmentPreamble> {
185        self.segment_preamble.as_ref()
186    }
187
188    /// Open a new WAL segment file with a specific starting LSN.
189    ///
190    /// Used by `SegmentedWal` when rolling to a new segment. The file must
191    /// not already exist (or be empty). The writer will assign LSNs starting
192    /// from `start_lsn`.
193    pub fn open_with_start_lsn(
194        path: &Path,
195        config: WalWriterConfig,
196        start_lsn: u64,
197    ) -> Result<Self> {
198        let file = open_segment_file(path, config.use_direct_io)?;
199        let buffer = AlignedBuf::new(config.write_buffer_size, config.alignment)?;
200
201        let dwb = open_dwb_for(&config, path);
202
203        Ok(Self {
204            file,
205            buffer,
206            file_offset: 0,
207            next_lsn: AtomicU64::new(start_lsn),
208            sealed: false,
209            durability: DurabilityState::new(),
210            config,
211            encryption_ring: None,
212            segment_preamble: None,
213            double_write: dwb.buffer,
214            dwb_path: dwb.path,
215            dwb_protection: dwb.protection,
216            dwb_unprotected_records: 0,
217            unanchored: false,
218            pending_anchor: None,
219        })
220    }
221
222    /// Open a WAL writer with O_DIRECT disabled, for a test whose subject is
223    /// buffered I/O or which runs on a filesystem that refuses the flag.
224    pub fn open_without_direct_io(path: &Path) -> Result<Self> {
225        Self::open(
226            path,
227            WalWriterConfig {
228                use_direct_io: false,
229                ..Default::default()
230            },
231        )
232    }
233
234    /// Bytes reserved at the tail of the write buffer for the alignment
235    /// padding record `flush_buffer` appends under O_DIRECT.
236    ///
237    /// The worst case is a batch that ends one byte short of a boundary: the
238    /// remaining gap cannot hold a header, so the padding record borrows a
239    /// whole extra block.
240    pub(super) fn padding_reserve(&self) -> usize {
241        if self.config.use_direct_io {
242            self.config.alignment + MIN_PADDING_RECORD_SIZE
243        } else {
244            0
245        }
246    }
247
248    /// Append a record to the WAL. Returns the assigned LSN.
249    ///
250    /// The record is written to the in-memory buffer. Call `sync()` to
251    /// flush to disk and make the write durable.
252    ///
253    /// `database_id` is stored in header bytes 34-41. Pass `0` for the
254    /// default database (backward-compatible with pre-existing records).
255    ///
256    /// The LSN is committed only once the record is in the write buffer. An
257    /// append that fails — a full device, an oversized payload — leaves the
258    /// LSN sequence untouched, so the next append reuses it rather than
259    /// leaving a hole that replay would have to reason about.
260    pub fn append(
261        &mut self,
262        record_type: u32,
263        tenant_id: u64,
264        vshard_id: u32,
265        database_id: u64,
266        payload: &[u8],
267    ) -> Result<u64> {
268        self.append_keyed(
269            RecordTarget {
270                record_type,
271                tenant_id,
272                vshard_id,
273                database_id,
274                event_source: crate::record::NO_EVENT_SOURCE,
275                commit_hlc: 0,
276            },
277            payload,
278            0,
279        )
280    }
281
282    /// [`Self::append`] for a record appended by the apply of the replicated
283    /// proposal `apply_key`, carrying the event source in `target` (see
284    /// [`WalRecord::new_stamped`]).
285    pub fn append_keyed(
286        &mut self,
287        target: RecordTarget,
288        payload: &[u8],
289        apply_key: u64,
290    ) -> Result<u64> {
291        self.append_reserving(target, payload, apply_key, self.anchor_reserve())
292    }
293
294    /// Append a record and leave `extra_reserve` bytes free in the buffer
295    /// beyond the padding reserve.
296    pub(super) fn append_reserving(
297        &mut self,
298        target: RecordTarget,
299        payload: &[u8],
300        apply_key: u64,
301        extra_reserve: usize,
302    ) -> Result<u64> {
303        let RecordTarget {
304            record_type,
305            tenant_id,
306            vshard_id,
307            database_id,
308            event_source,
309            commit_hlc,
310        } = target;
311        if self.sealed {
312            return Err(WalError::Sealed);
313        }
314        self.durability.check()?;
315
316        let lsn = self.next_lsn.load(Ordering::Relaxed);
317        let preamble_bytes = self.segment_preamble.as_ref().map(|p| p.to_bytes());
318        let record = WalRecord::new_stamped(
319            WalRecordArgs {
320                record_type,
321                lsn,
322                tenant_id,
323                vshard_id,
324                database_id,
325                payload: payload.to_vec(),
326                encryption_key: self.encryption_ring.as_ref().map(|r| r.current()),
327                preamble_bytes: preamble_bytes.as_ref(),
328            },
329            crate::record::RecordStamp {
330                apply_key,
331                event_source,
332                commit_hlc,
333            },
334        )?;
335
336        let header_bytes = record.header.to_bytes();
337        let total_size = HEADER_SIZE + record.payload.len();
338        let reserve = self.padding_reserve() + extra_reserve;
339
340        // If the record cannot fit in an empty buffer alongside its padding,
341        // no amount of flushing will make room.
342        let usable = self.buffer.capacity().saturating_sub(reserve);
343        if total_size > usable {
344            return Err(WalError::PayloadTooLarge {
345                size: record.payload.len(),
346                max: usable.saturating_sub(HEADER_SIZE),
347            });
348        }
349
350        // If this record doesn't fit in the remaining buffer, flush first.
351        // A failed flush propagates before the LSN is committed.
352        if self.buffer.remaining() < total_size + reserve {
353            self.flush_buffer()?;
354        }
355
356        self.buffer.write(&header_bytes);
357        self.buffer.write(&record.payload);
358
359        // Stage into the double-write buffer (in memory, no disk write) only
360        // once the record is committed to the write buffer. The DWB is keyed
361        // by LSN, so a record that failed to land in the WAL must not leave a
362        // phantom entry behind: this LSN goes to the next append, and torn-
363        // write recovery would then resurrect the wrong record for it.
364        //
365        // `sync()` writes the surviving staged slots and fsyncs the DWB before
366        // the WAL fsync. One batch costs at most `DWB_CAPACITY` slot writes
367        // and one DWB fsync, however many records it holds.
368        self.mirror_into_dwb(lsn, &record);
369
370        self.next_lsn.store(lsn + 1, Ordering::Relaxed);
371        self.unanchored = true;
372
373        Ok(lsn)
374    }
375
376    /// Flush the write buffer to disk (group commit).
377    ///
378    /// This issues a single write + fsync for all records accumulated
379    /// since the last flush. The DWB is also fsynced (one fsync for all
380    /// deferred DWB writes in this batch).
381    ///
382    /// An empty buffer alone does not mean there is nothing to do: a flush
383    /// clears the buffer before the fsync runs, so a batch whose fsync failed
384    /// leaves the writer empty-buffered with its records still only in the
385    /// page cache. Returning early there would report durability the log does
386    /// not have, so the fsync is skipped only when nothing is outstanding.
387    pub fn sync(&mut self) -> Result<()> {
388        self.durability.check()?;
389
390        if self.durability.should_skip_sync(self.buffer.is_empty()) {
391            return Ok(());
392        }
393
394        // Crash injection: die before the DWB is made durable. Recovery must
395        // still produce the committed prefix — the DWB is a torn-write side
396        // channel, never a source of records the WAL itself lacks.
397        nodedb_types::fail_point_err!("wal::before_dwb_flush", |detail: String| WalError::Io(
398            std::io::Error::other(format!("failpoint wal::before_dwb_flush: {detail}"))
399        ));
400
401        // The anchor rides this batch's own write and fsync.
402        self.append_time_anchor()?;
403
404        // Flush DWB first — records must be durable in DWB before WAL.
405        self.flush_dwb();
406        self.flush_buffer()?;
407
408        // Crash injection: die between the DWB fsync and the WAL fsync — the
409        // window where the DWB holds records the WAL has not yet committed.
410        nodedb_types::fail_point_err!("wal::before_wal_fsync", |detail: String| WalError::Io(
411            std::io::Error::other(format!("failpoint wal::before_wal_fsync: {detail}"))
412        ));
413
414        fsync_and_track(&self.file, &mut self.durability)?;
415        self.publish_time_anchor();
416        Ok(())
417    }
418
419    /// Seal the WAL — no more writes will be accepted.
420    ///
421    /// Flushes any buffered data before sealing. Sealing is a durability
422    /// boundary — the next segment starts where this one stops — so it goes
423    /// through `sync`, which fsyncs an outstanding flush even when the buffer
424    /// is empty and refuses to succeed on a poisoned writer.
425    pub fn seal(&mut self) -> Result<()> {
426        self.sync()?;
427        self.sealed = true;
428        Ok(())
429    }
430
431    /// The next LSN that will be assigned.
432    pub fn next_lsn(&self) -> u64 {
433        self.next_lsn.load(Ordering::Relaxed)
434    }
435
436    /// Current file size (bytes written to disk).
437    pub fn file_offset(&self) -> u64 {
438        self.file_offset
439    }
440
441    /// The largest payload one record takes: the write buffer, less the
442    /// padding and anchor reserves, the header and the encryption tag.
443    pub fn max_payload(&self) -> usize {
444        let tag = if self.encryption_ring().is_some() {
445            crate::crypto::AUTH_TAG_SIZE
446        } else {
447            0
448        };
449        self.buffer
450            .capacity()
451            .saturating_sub(self.padding_reserve() + self.anchor_reserve())
452            .saturating_sub(HEADER_SIZE + tag)
453            .min(crate::record::MAX_WAL_PAYLOAD_SIZE)
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::record::RecordType;
461
462    #[test]
463    fn write_and_sync_single_record() {
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("test.wal");
466
467        let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
468        let lsn = writer
469            .append(RecordType::Put as u32, 1, 0, 0, b"hello")
470            .unwrap();
471        assert_eq!(lsn, 1);
472
473        writer.sync().unwrap();
474        assert!(writer.file_offset() > 0);
475    }
476
477    #[test]
478    fn lsn_increments() {
479        let dir = tempfile::tempdir().unwrap();
480        let path = dir.path().join("test.wal");
481
482        let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
483
484        let lsn1 = writer
485            .append(RecordType::Put as u32, 1, 0, 0, b"first")
486            .unwrap();
487        let lsn2 = writer
488            .append(RecordType::Put as u32, 1, 0, 0, b"second")
489            .unwrap();
490        let lsn3 = writer
491            .append(RecordType::Put as u32, 1, 0, 0, b"third")
492            .unwrap();
493
494        assert_eq!(lsn1, 1);
495        assert_eq!(lsn2, 2);
496        assert_eq!(lsn3, 3);
497    }
498
499    #[test]
500    fn sealed_writer_rejects_writes() {
501        let dir = tempfile::tempdir().unwrap();
502        let path = dir.path().join("test.wal");
503
504        let mut writer = WalWriter::open_without_direct_io(&path).unwrap();
505        writer.seal().unwrap();
506
507        assert!(matches!(
508            writer.append(RecordType::Put as u32, 1, 0, 0, b"rejected"),
509            Err(WalError::Sealed)
510        ));
511    }
512
513    /// The shape the durability barrier actually uses: many threads append
514    /// through one shared writer and a single `sync()` covers the whole batch.
515    /// Every record must survive, exactly once, with no LSN reused.
516    #[test]
517    fn concurrent_appends_through_a_shared_writer_all_land() {
518        use std::sync::{Arc, Mutex};
519
520        let dir = tempfile::tempdir().unwrap();
521        let path = dir.path().join("test.wal");
522
523        let writer = Arc::new(Mutex::new(
524            WalWriter::open_without_direct_io(&path).unwrap(),
525        ));
526
527        let handles: Vec<_> = (0..10)
528            .map(|i| {
529                let w = Arc::clone(&writer);
530                std::thread::spawn(move || {
531                    let payload = format!("record-{i}");
532                    let mut guard = w.lock().unwrap();
533                    guard
534                        .append(RecordType::Put as u32, 1, 0, 0, payload.as_bytes())
535                        .unwrap()
536                })
537            })
538            .collect();
539
540        let mut lsns: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
541        lsns.sort_unstable();
542        assert_eq!(lsns, (1..=10).collect::<Vec<u64>>());
543
544        writer.lock().unwrap().sync().unwrap();
545
546        let reader = crate::reader::WalReader::open(&path, None).unwrap();
547        let records: Vec<_> = reader.records().collect::<Result<Vec<_>>>().unwrap();
548        assert_eq!(records.len(), 10);
549    }
550}