tidaldb/tidal/src/wal/compaction.rs
jx12n 9728194f16 fix: M0-M10 code-review pass2 remediation — all 91 findings
Resolves every finding in docs/reviews/M0-M10-code-review-2026-06-08-pass2.md
across the engine, network, server, and CLI crates: session restore,
replication/CRDT, WAL format and recovery, storage indexes, query/ranking
executors, cohort/community governance, and scatter-gather routing.

Adds regression tests:
- review_pass2_creator_search_filter
- review_pass2_d_replication
- review_pass2_query_for_session
- review_pass2_storage_indexes_bitmap_cache
- review_pass2_zone_a_sessions

Verified: cargo clippy -D warnings and full test suite green across all crates.
2026-06-09 12:21:00 -06:00

355 lines
14 KiB
Rust

//! 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<CompactionResult, WalError> {
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<CompactionResult, WalError> {
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<CompactionResult, WalError> {
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);
}
}