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}