Skip to main content

writeahead/
writer.rs

1//! The write-side actor: owns the active log file and the directory
2//! lifecycle. All writes funnel through one thread, which group-commits
3//! queued batches (one pwrite + one fdatasync for everything pending),
4//! rotates before a commit would exceed `max_file_size`, and applies retention.
5//!
6//! Rotation creates the next file *before* sealing the old one, so every
7//! crash window lands in a state startup recovery already heals: an
8//! unsealed non-active file is scanned and sealed, an empty highest file
9//! becomes the active file.
10
11use anyhow::{anyhow, Context, Result};
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14use std::sync::{Arc, RwLock, Weak};
15use std::thread;
16use tracing::{debug, error, trace, warn};
17
18use crate::fileio::{sync_dir, FileIo};
19use crate::logfile::{
20    append_footer, encode_records, log_file_path, now_ms, read_commit_state, write_committed_end,
21    write_header, CommitState, Logfile, LogfileError, FILE_HEADER_SIZE, FOOTER_SIZE,
22    RECORD_HEADER_SIZE,
23};
24use crate::record::RecordID;
25use crate::write_ahead::{ReaderMap, SharedReaders, WriteAheadError, WriteAheadOptions};
26
27pub(crate) enum WriterCommand {
28    Write(flume::Sender<Result<WriteAck>>, Vec<Vec<u8>>),
29    Trim(flume::Sender<TrimStats>, u64),
30}
31
32/// What an explicit [WriteHandle::trim_before] call deleted.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct TrimStats {
35    pub files_deleted: u64,
36    /// Lengths of unlinked files. Storage may remain pinned by open streams.
37    pub bytes_reclaimed: u64,
38}
39
40#[derive(Debug)]
41pub(crate) struct WriteAck {
42    /// The file the batch landed in (rotation can happen between submission
43    /// and commit, so this is part of the ack).
44    pub file_id: u64,
45    pub offsets: Vec<u64>,
46}
47
48#[derive(Debug, Clone)]
49pub(crate) struct FileMeta {
50    pub path: PathBuf,
51    pub size: u64,
52    pub seal_timestamp_ms: Option<u64>,
53}
54
55/// Cheaply cloneable handle for submitting durable writes from any task or
56/// thread. A write resolves once its records are fsync'd; writes submitted
57/// while a commit is in flight coalesce into the next group commit and
58/// share its fsync.
59///
60/// Handles stay valid across log rotations (each ack names the file the
61/// records landed in) and keep the writer alive even if the owning
62/// [crate::WriteAhead] is dropped.
63/// Dropping the last handle waits for accepted commands and thread exit.
64#[derive(Debug, Clone)]
65pub struct WriteHandle {
66    runtime: Arc<WriterRuntime>,
67}
68
69#[derive(Debug)]
70struct WriterRuntime {
71    tx: Option<flume::Sender<WriterCommand>>,
72    thread: Option<thread::JoinHandle<()>>,
73    max_batch_bytes: usize,
74}
75
76impl Drop for WriterRuntime {
77    fn drop(&mut self) {
78        // Last handle: close admission, drain accepted commands, then release
79        // the writer's directory lock. No actor retains this runtime Arc.
80        drop(self.tx.take());
81        if let Some(thread) = self.thread.take() {
82            let _ = thread.join();
83        }
84    }
85}
86
87impl WriteHandle {
88    /// Writes one record durably and returns its address.
89    pub async fn write(&self, record: Vec<u8>) -> Result<RecordID> {
90        let mut ids = self.write_batch(vec![record]).await?;
91        Ok(ids.remove(0))
92    }
93
94    /// Writes a batch of records durably (contiguous, all in one file) and
95    /// returns their addresses. Admission waits asynchronously when the queue
96    /// is full. Cancellation after admission does not roll back the write.
97    pub async fn write_batch(&self, records: Vec<Vec<u8>>) -> Result<Vec<RecordID>> {
98        if batch_bytes(&records)? > self.runtime.max_batch_bytes {
99            return Err(WriteAheadError::BatchTooLarge(self.runtime.max_batch_bytes).into());
100        }
101        let (tx, rx) = flume::bounded(1);
102        self.runtime
103            .tx
104            .as_ref()
105            .expect("live runtime")
106            .send_async(WriterCommand::Write(tx, records))
107            .await
108            .map_err(|_| WriteAheadError::WriterClosed)?;
109        let ack = rx
110            .recv_async()
111            .await
112            .map_err(|_| WriteAheadError::WriterClosed)??;
113        Ok(ack
114            .offsets
115            .iter()
116            .map(|offset| RecordID::new(ack.file_id, *offset))
117            .collect())
118    }
119
120    /// Deletes every sealed log file with id strictly below `file_id`,
121    /// reclaiming disk space the consumer no longer needs — e.g. once a
122    /// replay high-water mark has moved past a rotated file, or after an
123    /// explicit compaction rewrote its live records. The active file and
124    /// records at or above `file_id` are never touched.
125    ///
126    /// Resolves after the files are gone; failures to delete individual
127    /// files are logged and skipped (retryable). Streams created before the
128    /// trim keep their open file handles and finish undisturbed; new reads
129    /// into trimmed files return `LogfileNotFound`.
130    pub async fn trim_before(&self, file_id: u64) -> Result<TrimStats> {
131        let (tx, rx) = flume::bounded(1);
132        self.runtime
133            .tx
134            .as_ref()
135            .expect("live runtime")
136            .send_async(WriterCommand::Trim(tx, file_id))
137            .await
138            .map_err(|_| WriteAheadError::WriterClosed)?;
139        Ok(rx
140            .recv_async()
141            .await
142            .map_err(|_| WriteAheadError::WriterClosed)?)
143    }
144}
145
146type ReplyTx = flume::Sender<Result<WriteAck>>;
147
148/// Stop draining a group-commit round once this many payload bytes are
149/// queued, so latency stays bounded under a firehose of writers.
150const MAX_GROUP_COMMIT_BYTES: usize = 4 * 1024 * 1024;
151const MAX_GROUP_COMMIT_COMMANDS: usize = 64;
152
153pub(crate) fn validate_options(options: &WriteAheadOptions) -> Result<()> {
154    if options.queue_capacity == 0
155        || options.max_batch_bytes == 0
156        || options
157            .max_batch_bytes
158            .checked_add(MAX_GROUP_COMMIT_BYTES)
159            .is_none()
160    {
161        return Err(WriteAheadError::InvalidQueueOptions.into());
162    }
163    Ok(())
164}
165
166fn batch_bytes(records: &[Vec<u8>]) -> Result<usize> {
167    records.iter().try_fold(0usize, |total, record| {
168        if record.len() >= u32::MAX as usize {
169            return Err(LogfileError::RecordTooLarge.into());
170        }
171        total
172            .checked_add(RECORD_HEADER_SIZE as usize)
173            .and_then(|size| size.checked_add(record.len()))
174            .ok_or_else(|| WriteAheadError::BatchTooLarge(usize::MAX).into())
175    })
176}
177
178/// Check both limits before receiving another command, so ending a round
179/// never consumes a command that belongs to the next one.
180fn command_group(
181    first: WriterCommand,
182    recv: &flume::Receiver<WriterCommand>,
183) -> impl Iterator<Item = WriterCommand> + '_ {
184    let mut first = Some(first);
185    let mut bytes = 0usize;
186    let mut commands = 0usize;
187    std::iter::from_fn(move || {
188        if commands == MAX_GROUP_COMMIT_COMMANDS || bytes >= MAX_GROUP_COMMIT_BYTES {
189            return None;
190        }
191        let command = first.take().or_else(|| recv.try_recv().ok())?;
192        commands += 1;
193        if let WriterCommand::Write(_, data) = &command {
194            bytes += batch_bytes(data).expect("validated batch");
195        }
196        Some(command)
197    })
198}
199
200pub(crate) struct WalWriter<F: FileIo> {
201    options: WriteAheadOptions,
202    fio: F,
203    file_id: u64,
204    /// Logical end of committed records. Physical file length may be ahead
205    /// because the active file is sparsely grown in allocation windows.
206    records_end: u64,
207    allocated_end: u64,
208    commit_state: CommitState,
209    /// Every live log file (including the active one), for retention.
210    files: BTreeMap<u64, FileMeta>,
211    recv: flume::Receiver<WriterCommand>,
212    readers: Weak<RwLock<ReaderMap<F>>>,
213    _directory_lock: std::fs::File,
214}
215
216impl<F: FileIo + 'static> WalWriter<F> {
217    /// Opens the active file (writing its header if new) and applies startup
218    /// retention synchronously, then spawns the actor thread. `files` is the
219    /// recovered file registry, which may or may not include the active file.
220    pub(crate) fn launch(
221        options: WriteAheadOptions,
222        active_id: u64,
223        active_path: PathBuf,
224        mut files: BTreeMap<u64, FileMeta>,
225        directory_lock: std::fs::File,
226    ) -> Result<(WriteHandle, SharedReaders<F>)> {
227        let (tx, rx) = flume::bounded(options.queue_capacity);
228        let max_batch_bytes = options.max_batch_bytes;
229
230        let mut fio = F::open(&active_path)?;
231        let len = fio.len()?;
232        let (records_end, commit_state) = if len == 0 {
233            write_header(&mut fio)?;
234            fio.sync()?;
235            (FILE_HEADER_SIZE, read_commit_state(&fio)?)
236        } else if len < FILE_HEADER_SIZE {
237            // The manager recovers files before launching the writer, so a
238            // partial header here means that contract was violated.
239            return Err(anyhow!(LogfileError::InvalidHeader));
240        } else {
241            let state = read_commit_state(&fio)?;
242            (state.records_end, state)
243        };
244        // Also required on retry when a previous header sync succeeded but
245        // its directory sync failed. Visibility alone is not durability.
246        sync_dir(&options.log_dir)?;
247        let allocated_end = len.max(records_end);
248        files.insert(
249            active_id,
250            FileMeta {
251                path: active_path,
252                size: records_end,
253                seal_timestamp_ms: None,
254            },
255        );
256
257        let mut writer = Self {
258            options,
259            fio,
260            file_id: active_id,
261            records_end,
262            allocated_end,
263            commit_state,
264            files,
265            recv: rx,
266            readers: Weak::new(),
267            _directory_lock: directory_lock,
268        };
269        // Grow the active file after its header is durable. The allocation
270        // itself is covered by the next record sync and is never used as a
271        // logical record boundary.
272        writer.ensure_preallocated(records_end)?;
273        // Synchronous so `start()` returns with retention already applied
274        writer.apply_retention();
275
276        // Finish every fallible reader open before starting the actor. Failed
277        // startup drops all handles and the lock without a hidden writer.
278        let mut cache = BTreeMap::new();
279        for (id, meta) in &writer.files {
280            let logfile = Logfile::<F>::open(&meta.path)?;
281            let end = logfile.records_end()?;
282            cache.insert(*id, (Arc::new(logfile), end));
283        }
284        let readers = Arc::new(RwLock::new(cache));
285        writer.readers = Arc::downgrade(&readers);
286        let thread = thread::Builder::new()
287            .name("wal-writer".into())
288            .spawn(move || writer.actor_loop())
289            .context("Failed to spawn writer thread")?;
290        Ok((
291            WriteHandle {
292                runtime: Arc::new(WriterRuntime {
293                    tx: Some(tx),
294                    thread: Some(thread),
295                    max_batch_bytes,
296                }),
297            },
298            readers,
299        ))
300    }
301
302    fn actor_loop(mut self) {
303        while let Ok(first) = self.recv.recv() {
304            let mut writes: Vec<(ReplyTx, Vec<Vec<u8>>)> = Vec::new();
305            // Trims drained alongside writes run after the commit, so a trim
306            // submitted after a write never races that write's file.
307            let mut trims: Vec<(flume::Sender<TrimStats>, u64)> = Vec::new();
308            for command in command_group(first, &self.recv) {
309                match command {
310                    WriterCommand::Write(reply, data) => writes.push((reply, data)),
311                    WriterCommand::Trim(reply, upto) => trims.push((reply, upto)),
312                }
313            }
314
315            if !writes.is_empty() {
316                self.commit_group(writes);
317            }
318            for (reply, upto) in trims {
319                let _ = reply.send(self.trim_before(upto));
320            }
321
322            // Rotation failure is not fatal: the current file keeps
323            // accepting writes and rotation retries on the next commit.
324            if let Err(e) = self.maybe_rotate() {
325                error!("log rotation failed: {e:#}");
326            }
327        }
328        trace!("wal writer actor shut down");
329    }
330
331    /// Encodes every queued batch into one buffer, does one write and one
332    /// fsync, then answers each caller with its own offsets.
333    fn commit_group(&mut self, group: Vec<(ReplyTx, Vec<Vec<u8>>)>) {
334        // A dropped reply receiver is the caller's business, not our error:
335        // send results are ignored throughout.
336        // Admission already checked every record and batch. The drain limits
337        // bound this sum, so no second validation/copy of the group is needed.
338        let total: usize = group
339            .iter()
340            .map(|(_, records)| batch_bytes(records).expect("validated batch"))
341            .sum();
342        if total == 0 {
343            // Nothing to persist (all batches empty): ack without an fsync
344            for (reply, _) in group {
345                let _ = reply.send(Ok(WriteAck {
346                    file_id: self.file_id,
347                    offsets: Vec::new(),
348                }));
349            }
350            return;
351        }
352
353        // Keep the entire group in one file. If it does not fit in a
354        // non-empty active file, rotate before assigning offsets. A group
355        // larger than an empty file's capacity is accepted as a soft-cap
356        // exception, then the oversized file is rotated after the commit.
357        let total_u64 = total as u64;
358        let proposed_end = self.records_end.checked_add(total_u64);
359        let would_exceed = proposed_end
360            .map(|end| end > self.options.max_file_size)
361            .unwrap_or(true);
362        if would_exceed && self.records_end > FILE_HEADER_SIZE {
363            if let Err(e) = self.rotate() {
364                let msg = format!("Failed to rotate before commit: {e:#}");
365                for (reply, _) in group {
366                    let _ = reply.send(Err(anyhow!("{msg}")));
367                }
368                return;
369            }
370        }
371
372        let mut buf = Vec::with_capacity(total);
373        let mut current_offset = self.records_end;
374        let mut per_caller_offsets: Vec<Vec<u64>> = Vec::with_capacity(group.len());
375        for (_, records) in &group {
376            let offsets = encode_records(&mut buf, records, current_offset);
377            current_offset += batch_bytes(records).expect("validated batch") as u64;
378            per_caller_offsets.push(offsets);
379        }
380
381        let result = (|| -> Result<CommitState> {
382            self.ensure_preallocated(current_offset)?;
383            self.fio
384                .write_at(self.records_end, &buf)
385                .context("Failed to write records")?;
386            let state = write_committed_end(&mut self.fio, self.commit_state, current_offset)?;
387            // The record bytes, logical-end slot, and any allocation-window
388            // extension are made durable by this single fdatasync.
389            self.fio.sync().context("Failed to sync records")?;
390            Ok(state)
391        })();
392
393        match result {
394            Ok(commit_state) => {
395                self.records_end = current_offset;
396                self.commit_state = commit_state;
397                if let Some(meta) = self.files.get_mut(&self.file_id) {
398                    meta.size = current_offset;
399                }
400                if let Some(readers) = self.readers.upgrade() {
401                    readers
402                        .write()
403                        .expect("reader cache poisoned")
404                        .get_mut(&self.file_id)
405                        .expect("active reader")
406                        .1 = current_offset;
407                }
408                for ((reply, _), offsets) in group.into_iter().zip(per_caller_offsets) {
409                    let _ = reply.send(Ok(WriteAck {
410                        file_id: self.file_id,
411                        offsets,
412                    }));
413                }
414            }
415            Err(e) => {
416                // Nothing was acknowledged: records_end and commit_state stay
417                // put. Failed writes stay invisible to live readers, but may
418                // survive recovery if their bytes reached disk. anyhow::Error
419                // isn't Clone, so each caller gets its own copy of the message.
420                let msg = format!("{e:#}");
421                for (reply, _) in group {
422                    let _ = reply.send(Err(anyhow!("{msg}")));
423                }
424            }
425        }
426    }
427
428    fn maybe_rotate(&mut self) -> Result<()> {
429        if self.records_end <= self.options.max_file_size {
430            return Ok(());
431        }
432
433        self.rotate()
434    }
435
436    fn rotate(&mut self) -> Result<()> {
437        // Create the next file first: if we crash (or fail) between here and
438        // the seal below, recovery sees an unsealed non-active file and
439        // heals it, and an empty highest file becomes the active file.
440        let next_id = self
441            .file_id
442            .checked_add(1)
443            .ok_or(WriteAheadError::FileIdExhausted)?;
444        let path = log_file_path(&self.options.log_dir, next_id);
445        let mut fio = F::open(&path)?;
446        write_header(&mut fio)?;
447        fio.sync()?;
448        sync_dir(&self.options.log_dir)?;
449        let commit_state = read_commit_state(&fio)?;
450        let readers = self.readers.upgrade();
451        let reader = readers
452            .as_ref()
453            .map(|_| Logfile::<F>::open(&path))
454            .transpose()?;
455        self.files.insert(
456            next_id,
457            FileMeta {
458                path: path.clone(),
459                size: FILE_HEADER_SIZE,
460                seal_timestamp_ms: None,
461            },
462        );
463        if let (Some(readers), Some(reader)) = (readers, reader) {
464            readers
465                .write()
466                .expect("reader cache poisoned")
467                .insert(next_id, (Arc::new(reader), FILE_HEADER_SIZE));
468        }
469
470        // Seal the old file. On failure it stays unsealed and recovery
471        // heals it at the next start; new writes still go to the new file.
472        let ts = now_ms();
473        match append_footer(&mut self.fio, self.records_end, ts) {
474            Ok(()) => {
475                if let Some(meta) = self.files.get_mut(&self.file_id) {
476                    meta.size = self.records_end + FOOTER_SIZE;
477                    meta.seal_timestamp_ms = Some(ts);
478                }
479                debug!("sealed log file {}", self.file_id);
480            }
481            Err(e) => warn!("failed to seal log file {}: {e:#}", self.file_id),
482        }
483
484        self.fio = fio;
485        self.file_id = next_id;
486        self.records_end = FILE_HEADER_SIZE;
487        self.allocated_end = FILE_HEADER_SIZE;
488        self.commit_state = commit_state;
489        self.ensure_preallocated(FILE_HEADER_SIZE)?;
490
491        self.apply_retention();
492        Ok(())
493    }
494
495    /// Grows the active file to the allocation window covering required_end.
496    /// None (and defensively Some(0)) leaves normal EOF-extending writes in
497    /// place. Windows never extend beyond max_file_size unless a single
498    /// oversized group is already past that soft cap.
499    fn ensure_preallocated(&mut self, required_end: u64) -> Result<()> {
500        let Some(chunk) = self
501            .options
502            .preallocation_chunk_size
503            .filter(|size| *size > 0)
504        else {
505            return Ok(());
506        };
507        let rounded = required_end
508            .checked_add(chunk - 1)
509            .map(|value| value / chunk * chunk)
510            .unwrap_or(u64::MAX);
511        let target = if required_end <= self.options.max_file_size {
512            rounded.min(self.options.max_file_size).max(required_end)
513        } else {
514            // Oversized batches retain the existing soft-cap behavior but do
515            // not allocate still farther past the cap.
516            required_end
517        };
518        if target <= self.allocated_end {
519            return Ok(());
520        }
521        self.fio
522            .set_len(target)
523            .with_context(|| format!("Failed to preallocate active logfile to {target} bytes"))?;
524        self.allocated_end = target;
525        Ok(())
526    }
527
528    /// Deletes sealed files per `RetentionOptions`. The active file is never
529    /// deleted. Failures are logged, not fatal — retention retries on the
530    /// next rotation.
531    fn apply_retention(&mut self) {
532        if let Some(meta) = self.files.get_mut(&self.file_id) {
533            meta.size = self.records_end;
534        }
535
536        let mut doomed: Vec<u64> = Vec::new();
537
538        let ttl_ms = self.options.retention.ttl.as_millis() as u64;
539        if ttl_ms > 0 {
540            let cutoff = now_ms().saturating_sub(ttl_ms);
541            doomed.extend(
542                self.files
543                    .iter()
544                    .filter(|(id, meta)| {
545                        **id != self.file_id && meta.seal_timestamp_ms.is_some_and(|ts| ts < cutoff)
546                    })
547                    .map(|(id, _)| *id),
548            );
549        }
550
551        let max_total = self.options.retention.max_total_size;
552        if max_total > 0 {
553            let doomed_size: u64 = doomed
554                .iter()
555                .filter_map(|id| self.files.get(id))
556                .map(|m| m.size)
557                .sum();
558            let mut total: u64 = self.files.values().map(|m| m.size).sum::<u64>() - doomed_size;
559            for (id, meta) in &self.files {
560                if total <= max_total {
561                    break;
562                }
563                if *id == self.file_id || meta.seal_timestamp_ms.is_none() || doomed.contains(id) {
564                    continue;
565                }
566                doomed.push(*id);
567                total -= meta.size;
568            }
569        }
570
571        self.delete_files(doomed, "retention");
572    }
573
574    /// Deletes every sealed file with id strictly below `upto`. The active
575    /// file is never deleted; a non-active file that missed its seal (failed
576    /// rotation) is skipped until recovery heals it.
577    fn trim_before(&mut self, upto: u64) -> TrimStats {
578        let doomed: Vec<u64> = self
579            .files
580            .iter()
581            .filter(|(id, meta)| {
582                **id < upto && **id != self.file_id && meta.seal_timestamp_ms.is_some()
583            })
584            .map(|(id, _)| *id)
585            .collect();
586        self.delete_files(doomed, "trim")
587    }
588
589    /// Removes files from disk and the registry, notifying the read side.
590    /// Per-file failures are logged and the file is kept (retryable).
591    fn delete_files(&mut self, doomed: Vec<u64>, why: &str) -> TrimStats {
592        let mut stats = TrimStats::default();
593        if doomed.is_empty() {
594            return stats;
595        }
596        for id in doomed {
597            if let Some(meta) = self.files.remove(&id) {
598                debug!("{}: deleting logfile {}", why, id);
599                if let Err(e) = std::fs::remove_file(&meta.path) {
600                    warn!("{}: failed to delete {}: {e}", why, meta.path.display());
601                    self.files.insert(id, meta);
602                    continue;
603                }
604                stats.files_deleted += 1;
605                stats.bytes_reclaimed += meta.size;
606                if let Some(readers) = self.readers.upgrade() {
607                    readers.write().expect("reader cache poisoned").remove(&id);
608                }
609            }
610        }
611        if let Err(e) = sync_dir(&self.options.log_dir) {
612            warn!("{}: failed to sync log dir: {e:#}", why);
613        }
614        stats
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn zero_byte_commands_have_bounded_rounds_without_dropping_work() {
624        let (tx, rx) = flume::bounded(3 * MAX_GROUP_COMMIT_COMMANDS);
625        for i in 0..3 * MAX_GROUP_COMMIT_COMMANDS {
626            if i % 2 == 0 {
627                tx.send(WriterCommand::Trim(flume::bounded(1).0, i as u64))
628                    .unwrap();
629            } else {
630                tx.send(WriterCommand::Write(flume::bounded(1).0, Vec::new()))
631                    .unwrap();
632            }
633        }
634        for remaining in (0..3).rev() {
635            let group: Vec<_> = command_group(rx.recv().unwrap(), &rx).collect();
636            assert_eq!(group.len(), MAX_GROUP_COMMIT_COMMANDS);
637            assert_eq!(rx.len(), remaining * MAX_GROUP_COMMIT_COMMANDS);
638        }
639    }
640
641    #[test]
642    fn byte_limit_does_not_consume_the_next_command() {
643        let (tx, rx) = flume::bounded(2);
644        tx.send(WriterCommand::Trim(flume::bounded(1).0, 7))
645            .unwrap();
646        let first = WriterCommand::Write(
647            flume::bounded(1).0,
648            vec![vec![
649                0;
650                MAX_GROUP_COMMIT_BYTES - RECORD_HEADER_SIZE as usize
651            ]],
652        );
653        assert_eq!(command_group(first, &rx).count(), 1);
654        assert!(matches!(rx.try_recv().unwrap(), WriterCommand::Trim(_, 7)));
655    }
656}