1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct TrimStats {
35 pub files_deleted: u64,
36 pub bytes_reclaimed: u64,
38}
39
40#[derive(Debug)]
41pub(crate) struct WriteAck {
42 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#[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 drop(self.tx.take());
81 if let Some(thread) = self.thread.take() {
82 let _ = thread.join();
83 }
84 }
85}
86
87impl WriteHandle {
88 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 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 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
148const 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
178fn 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 records_end: u64,
207 allocated_end: u64,
208 commit_state: CommitState,
209 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 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 return Err(anyhow!(LogfileError::InvalidHeader));
240 } else {
241 let state = read_commit_state(&fio)?;
242 (state.records_end, state)
243 };
244 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 writer.ensure_preallocated(records_end)?;
273 writer.apply_retention();
275
276 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 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 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 fn commit_group(&mut self, group: Vec<(ReplyTx, Vec<Vec<u8>>)>) {
334 let total: usize = group
339 .iter()
340 .map(|(_, records)| batch_bytes(records).expect("validated batch"))
341 .sum();
342 if total == 0 {
343 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 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 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 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 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 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 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 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 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 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 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}