//! WAL segment compaction. //! //! Deletes WAL segments that are fully covered by a checkpoint. //! This prevents unbounded WAL growth after checkpoints succeed. use std::path::Path; use super::{error::WalError, segment}; /// Result of a compaction operation. #[derive(Debug)] pub struct CompactionResult { /// Number of WAL segments deleted. pub segments_deleted: usize, /// Total bytes reclaimed (sum of deleted segment file sizes). pub bytes_reclaimed: u64, /// Remaining segment count after compaction. pub segments_remaining: usize, } /// Delete WAL segments that are fully covered by the checkpoint at `checkpoint_seq`. /// /// A segment with `first_seq < checkpoint_seq` is safe to delete because all its /// events have been materialized to the signal ledger checkpoint. A segment with /// `first_seq == checkpoint_seq` may contain events both before and after the /// checkpoint; it is NOT deleted (conservative: we replay a few extra events /// rather than risk losing uncovered events). /// /// # Live-writer hazard — use [`compact_wal_online`] instead while the WAL is open /// /// This function does NOT account for the segment a running writer thread is /// actively appending to. The writer holds one segment open and only rotates at /// the size threshold, so after any burst the live segment's `first_seq` is well /// below the materialized checkpoint — and this function would `remove_file` it /// out from under the writer's open FD. On Linux the writer keeps appending to /// the now-unlinked inode and every post-checkpoint, already-fsync'd write is /// silently lost on the next open. Therefore this entry point is only safe to /// call once the writer thread has been joined (i.e. after /// `WalHandle::shutdown()`); the periodic-checkpoint path must use /// [`compact_wal_online`]. /// /// # Safety invariant /// /// The caller must ensure the checkpoint at `checkpoint_seq` has been durably /// committed to storage BEFORE calling this function. The order is: /// /// 1. `ledger.checkpoint(storage, meta)` -- durable /// 2. `storage.flush()` -- durable /// 3. `compact_wal(wal_dir, meta.wal_sequence)` -- safe to lose old segments /// /// If we crash between steps 1 and 3, old segments survive and are replayed /// redundantly on next open. This is correct (idempotent). /// /// If we crash during step 3 (partial deletion), some segments are deleted /// and others are not. This is also correct: deleted segments are covered /// by the checkpoint, surviving segments are replayed. /// /// # Errors /// /// Returns `WalError::Io` on filesystem failure. Partial deletion may occur /// if an error is encountered mid-way -- this is safe (see invariant above). pub fn compact_wal(wal_dir: &Path, checkpoint_seq: u64) -> Result { let segments = segment::list_segments(wal_dir)?; compact_segments(wal_dir, &segments, checkpoint_seq) } /// Online-safe compaction: identical to [`compact_wal`] except it NEVER deletes /// the segment the WAL writer thread is (or may be) actively appending to. /// /// `list_segments` returns segments sorted by ascending `first_seq`, and the /// writer always appends to — and only ever rotates to a strictly *higher* — /// the segment with the largest `first_seq` (see `WalHandle::open`, which opens /// `segments.last()`). So the live segment is always the maximum-`first_seq` /// segment on disk. We clamp the deletion floor to that segment's `first_seq`, /// guaranteeing it survives even when the checkpoint has advanced past its /// `first_seq` — the common case, since the active segment starts before a /// checkpoint but still holds uncheckpointed tail events. Deleting it would /// unlink the inode out from under the writer's open FD and, on Linux, silently /// lose every post-checkpoint, already-fsync'd write that followed. /// /// Safe to run concurrently with the writer: a rotation during compaction only /// creates an even-higher segment (untouched here) and seals the old active one /// (now safely deletable on the *next* compaction) — never a lost write. /// /// Use this from the periodic-checkpoint path while the writer is live; use /// [`compact_wal`] only after `WalHandle::shutdown()` has joined the writer. /// /// # Errors /// /// Returns `WalError::Io` on filesystem failure. pub fn compact_wal_online( wal_dir: &Path, checkpoint_seq: u64, ) -> Result { let segments = segment::list_segments(wal_dir)?; // The live segment is the maximum-`first_seq` segment; never delete at or // above it. Clamp the floor so the writer's open segment is always preserved. let floor = match segments.iter().map(|(first_seq, _)| *first_seq).max() { Some(active_first_seq) => checkpoint_seq.min(active_first_seq), None => checkpoint_seq, }; compact_segments(wal_dir, &segments, floor) } /// Delete every listed segment whose `first_seq` is strictly less than `floor`, /// then fsync the directory so the unlinks are durable. /// /// `floor` is the exclusive lower bound: a segment is kept iff /// `first_seq >= floor`. [`compact_wal`] passes `checkpoint_seq` directly; /// [`compact_wal_online`] passes `min(checkpoint_seq, active_first_seq)` to /// protect the live segment. fn compact_segments( wal_dir: &Path, segments: &[(u64, std::path::PathBuf)], floor: u64, ) -> Result { let total_before = segments.len(); let mut deleted = 0usize; let mut bytes_reclaimed = 0u64; for (seg_first_seq, seg_path) in segments { if *seg_first_seq < floor { // SAFETY: TOCTOU gap between metadata() and remove_file() is benign. // `list_segments()` only returns files that exist on disk at scan time. // If another process deleted this segment between the scan and now, // remove_file() returns an error which propagates -- but TidalDb // enforces single-process-per-directory ownership (see Config::lock_dir), // so this cannot happen in production. The gap is a safe no-op under the // single-owner invariant. let file_size = std::fs::metadata(seg_path).map(|m| m.len()).unwrap_or(0); std::fs::remove_file(seg_path)?; deleted += 1; bytes_reclaimed += file_size; tracing::debug!( segment_first_seq = seg_first_seq, file_size, "compacted WAL segment" ); } } // Fsync the directory to ensure the unlink operations are durable. // Without this, a crash after deletion but before directory metadata // flush could "resurrect" deleted segment files. On macOS a plain // directory fsync does not flush the device cache, so route through the // F_FULLFSYNC-backed helper. This is the same durable-delete contract as // `segment::delete_segments_before`. if deleted > 0 { crate::wal::sync_dir_durable(wal_dir)?; } let remaining = total_before - deleted; tracing::info!( deleted, bytes_reclaimed, remaining, floor, "WAL compaction complete" ); Ok(CompactionResult { segments_deleted: deleted, bytes_reclaimed, segments_remaining: remaining, }) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use std::fs; use super::*; use crate::{ replication::ShardId, wal::segment::{SegmentWriter, list_segments, segment_filename}, }; #[test] fn compact_empty_dir() { let dir = tempfile::tempdir().unwrap(); let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 0); assert_eq!(result.segments_remaining, 0); } #[test] fn compact_deletes_old_segments() { let dir = tempfile::tempdir().unwrap(); // Create segments at seq 1, 50, 100, 200. for &seq in &[1u64, 50, 100, 200] { let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap(); } assert_eq!(list_segments(dir.path()).unwrap().len(), 4); // Compact with checkpoint at seq=100. // Segments 1 and 50 have first_seq < 100, so they are deleted. // Segments 100 and 200 are preserved. let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 2); assert_eq!(result.segments_remaining, 2); let remaining = list_segments(dir.path()).unwrap(); assert_eq!(remaining[0].0, 100); assert_eq!(remaining[1].0, 200); } #[test] fn compact_preserves_current_segment() { let dir = tempfile::tempdir().unwrap(); let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 100, 1024).unwrap(); // Checkpoint at seq=100: segment starting at 100 is NOT deleted // (it may contain events >= 100). let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 0); assert_eq!(result.segments_remaining, 1); } #[test] fn compact_no_segments_to_delete() { let dir = tempfile::tempdir().unwrap(); let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 500, 1024).unwrap(); let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 0); assert_eq!(result.segments_remaining, 1); } #[test] fn compact_all_segments_old() { let dir = tempfile::tempdir().unwrap(); for &seq in &[1u64, 10, 20] { let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap(); } let result = compact_wal(dir.path(), 1000).unwrap(); assert_eq!(result.segments_deleted, 3); assert_eq!(result.segments_remaining, 0); } #[test] fn compact_idempotent() { let dir = tempfile::tempdir().unwrap(); let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 1024).unwrap(); let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 100, 1024).unwrap(); compact_wal(dir.path(), 100).unwrap(); // Running compaction again should be a no-op. let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 0); assert_eq!(result.segments_remaining, 1); } #[test] fn compact_crash_during_deletion_is_safe() { let dir = tempfile::tempdir().unwrap(); for &seq in &[1u64, 50, 100, 200] { let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap(); } // Simulate a crash mid-compaction: segment 1 was deleted but segment 50 wasn't. // On restart, compact_wal is called again with the same checkpoint_seq. let seg1_path = dir.path().join(segment_filename(ShardId::SINGLE, 1)); fs::remove_file(&seg1_path).unwrap(); // Compaction should succeed: seg 50 is deleted, segs 100 and 200 survive. let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 1); // only seg 50 deleted (seg 1 already gone) assert_eq!(result.segments_remaining, 2); let remaining = list_segments(dir.path()).unwrap(); assert_eq!(remaining[0].0, 100); assert_eq!(remaining[1].0, 200); } // Keep the old SegmentWriter drop behavior from causing test issues. // SegmentWriter drops may write empty segment files. Verify compaction // still works correctly. #[test] fn compact_handles_empty_segment_files() { let dir = tempfile::tempdir().unwrap(); // Write a segment file directly (bypassing SegmentWriter to avoid I/O). let seg1_path = dir.path().join(segment_filename(ShardId::SINGLE, 1)); fs::write(&seg1_path, []).unwrap(); let seg2_path = dir.path().join(segment_filename(ShardId::SINGLE, 100)); fs::write(&seg2_path, []).unwrap(); let result = compact_wal(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 1); // seq=1 < 100 assert_eq!(result.segments_remaining, 1); // seq=100 preserved } #[test] fn online_never_deletes_active_segment_even_when_fully_checkpointed() { // BLOCKER regression: the live (max-first_seq) segment must survive even // when the checkpoint has advanced far past its first_seq — otherwise the // writer's open FD points at an unlinked inode and acknowledged, fsync'd // writes are silently lost on Linux. let dir = tempfile::tempdir().unwrap(); for &seq in &[1u64, 100] { let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap(); } // Checkpoint way past both segments. Shutdown-compaction would delete both; // online compaction must preserve the active segment (first_seq = 100). let result = compact_wal_online(dir.path(), 1_000).unwrap(); assert_eq!( result.segments_deleted, 1, "only the non-active old segment" ); assert_eq!(result.segments_remaining, 1); let remaining = list_segments(dir.path()).unwrap(); assert_eq!(remaining[0].0, 100, "active segment preserved"); } #[test] fn online_preserves_single_active_segment() { let dir = tempfile::tempdir().unwrap(); let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 50, 1024).unwrap(); // Even with a checkpoint past it, the sole (active) segment is never deleted. let result = compact_wal_online(dir.path(), 1_000).unwrap(); assert_eq!(result.segments_deleted, 0); assert_eq!(result.segments_remaining, 1); } #[test] fn online_matches_shutdown_compaction_for_older_segments() { // When older segments sit below both the checkpoint and the active // segment, online compaction reclaims exactly the same ones. let dir = tempfile::tempdir().unwrap(); for &seq in &[1u64, 50, 100, 200] { let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap(); } // floor = min(100, max_first_seq=200) = 100 -> delete first_seq < 100. let result = compact_wal_online(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 2); assert_eq!(result.segments_remaining, 2); let remaining = list_segments(dir.path()).unwrap(); assert_eq!(remaining[0].0, 100); assert_eq!(remaining[1].0, 200); } #[test] fn online_empty_dir_is_noop() { let dir = tempfile::tempdir().unwrap(); let result = compact_wal_online(dir.path(), 100).unwrap(); assert_eq!(result.segments_deleted, 0); assert_eq!(result.segments_remaining, 0); } }