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.
355 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|