use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use crate::error::EdgestoreError;
use crate::manifest::Manifest;
use crate::types::{death_time_for, MemEntry, Operation, SegmentId, SegmentMeta};
#[derive(Debug, Default)]
pub struct CohortInfo {
pub cohort_bucket: i64,
pub segment_ids: Vec<SegmentId>,
pub max_death_time_nanos: i64,
pub total_records: u64,
pub dead_record_estimate: u64,
pub is_fully_expired: bool,
}
#[derive(Debug, Default)]
pub struct CompactionStats {
pub cohorts_collected: u64,
pub segments_removed: u64,
pub segments_written: u64,
pub bytes_written: u64,
pub live_records_relocated: u64,
}
#[derive(Debug)]
pub struct Compactor {
pub base_path: PathBuf,
pub write_budget_bytes: u64,
pub cohort_window_secs: u64,
}
impl Compactor {
pub fn new(
base_path: PathBuf,
write_budget_bytes: u64,
cohort_window_secs: u64,
) -> Self {
Compactor {
base_path,
write_budget_bytes,
cohort_window_secs,
}
}
pub fn identify_cohorts(segments: &[SegmentMeta], now_nanos: i64) -> Vec<CohortInfo> {
let mut by_bucket: HashMap<i64, Vec<&SegmentMeta>> = HashMap::new();
for seg in segments {
by_bucket.entry(seg.cohort_bucket).or_default().push(seg);
}
let mut cohorts: Vec<CohortInfo> = by_bucket
.into_iter()
.map(|(bucket, segs)| {
let max_death_time_nanos = segs.iter().map(|s| s.death_time).max().unwrap_or(0);
let total_records = segs.iter().map(|s| s.record_count).sum();
let segment_ids = segs.iter().map(|s| s.segment_id).collect();
let is_fully_expired = now_nanos > max_death_time_nanos;
CohortInfo {
cohort_bucket: bucket,
segment_ids,
max_death_time_nanos,
total_records,
dead_record_estimate: 0,
is_fully_expired,
}
})
.collect();
cohorts.sort_by(|a, b| {
match (a.is_fully_expired, b.is_fully_expired) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.max_death_time_nanos.cmp(&b.max_death_time_nanos),
}
});
cohorts
}
pub fn collect_expired_cohort(
&self,
manifest: &mut Manifest,
cohort: &CohortInfo,
stats: &mut CompactionStats,
) -> Result<(), EdgestoreError> {
manifest.remove_segments(&cohort.segment_ids)?;
stats.segments_removed += cohort.segment_ids.len() as u64;
stats.cohorts_collected += 1;
for &seg_id in &cohort.segment_ids {
let extensions = ["dat", "idx", "xf", "meta"];
for ext in extensions {
let path = self
.base_path
.join(format!("segment-{:08}.{}", seg_id, ext));
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(EdgestoreError::Io(e));
}
eprintln!(
"compactor: segment file not found (already deleted?): {}",
path.display()
);
}
}
}
Ok(())
}
pub fn compact_partial_cohort(
&self,
manifest: &mut Manifest,
cohort: &CohortInfo,
now_nanos: i64,
next_segment_id: SegmentId,
stats: &mut CompactionStats,
) -> Result<(), EdgestoreError> {
use crate::segment::{SegmentReader, SegmentWriter};
let mut merged: HashMap<Vec<u8>, MemEntry> = HashMap::new();
for &seg_id in &cohort.segment_ids {
let reader = SegmentReader::open(self.base_path.clone(), seg_id)?;
let entries = reader.range_scan(&[], &[0xFFu8; 256])?;
for (key, entry) in entries {
let existing_lsn = merged.get(&key).map(|e| e.lsn).unwrap_or(0);
if entry.lsn > existing_lsn {
merged.insert(key, entry);
}
}
}
let survivors: Vec<(Vec<u8>, MemEntry)> = merged
.into_iter()
.filter(|(_, entry)| {
if entry.op == Operation::Delete {
return false;
}
let dt = death_time_for(entry.timestamp, entry.ttl, self.cohort_window_secs);
dt > now_nanos
})
.collect();
if survivors.is_empty() {
manifest.remove_segments(&cohort.segment_ids)?;
stats.segments_removed += cohort.segment_ids.len() as u64;
stats.cohorts_collected += 1;
for &seg_id in &cohort.segment_ids {
let extensions = ["dat", "idx", "xf", "meta"];
for ext in extensions {
let path = self
.base_path
.join(format!("segment-{:08}.{}", seg_id, ext));
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(EdgestoreError::Io(e));
}
eprintln!(
"compactor: segment file not found: {}",
path.display()
);
}
}
}
return Ok(());
}
let mut sorted_survivors = survivors;
sorted_survivors.sort_by(|(a, _), (b, _)| a.cmp(b));
let survivor_count = sorted_survivors.len() as u64;
let mut writer =
SegmentWriter::new(self.base_path.clone(), next_segment_id, self.cohort_window_secs);
let new_meta = writer.flush(&sorted_survivors)?;
let bytes_written = new_meta.compressed_bytes;
manifest.add_segment(new_meta)?;
manifest.remove_segments(&cohort.segment_ids)?;
for &seg_id in &cohort.segment_ids {
let extensions = ["dat", "idx", "xf", "meta"];
for ext in extensions {
let path = self
.base_path
.join(format!("segment-{:08}.{}", seg_id, ext));
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(EdgestoreError::Io(e));
}
eprintln!(
"compactor: segment file not found: {}",
path.display()
);
}
}
}
stats.bytes_written += bytes_written;
stats.live_records_relocated += survivor_count;
stats.segments_written += 1;
stats.segments_removed += cohort.segment_ids.len() as u64;
stats.cohorts_collected += 1;
Ok(())
}
pub fn compact_cycle(
&self,
manifest: &mut Manifest,
now_nanos: i64,
pinned_segment_ids: &HashSet<SegmentId>,
) -> Result<CompactionStats, EdgestoreError> {
let mut stats = CompactionStats::default();
let segments: Vec<SegmentMeta> = manifest
.list_segments()
.iter()
.filter(|m| !pinned_segment_ids.contains(&m.segment_id))
.cloned()
.collect();
let cohorts = Self::identify_cohorts(&segments, now_nanos);
let mut next_segment_id: SegmentId = manifest
.list_segments()
.iter()
.map(|m| m.segment_id)
.max()
.unwrap_or(0)
+ 1;
for cohort in &cohorts {
if stats.bytes_written >= self.write_budget_bytes {
break;
}
let any_pinned = cohort
.segment_ids
.iter()
.any(|id| pinned_segment_ids.contains(id));
if any_pinned {
continue;
}
if cohort.is_fully_expired {
self.collect_expired_cohort(manifest, cohort, &mut stats)?;
} else {
self.compact_partial_cohort(
manifest,
cohort,
now_nanos,
next_segment_id,
&mut stats,
)?;
next_segment_id += 1;
}
}
Ok(stats)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use crate::segment::SegmentWriter;
use crate::types::{encode_key, MemEntry, Operation, SegmentMeta};
use tempfile::TempDir;
fn make_segment_meta(segment_id: u64, cohort_bucket: i64, death_time: i64, record_count: u64) -> SegmentMeta {
SegmentMeta {
segment_id,
segment_hash: vec![0u8; 32],
min_key: b"aaa".to_vec(),
max_key: b"zzz".to_vec(),
min_lsn: 1,
max_lsn: 100,
record_count,
compressed_bytes: 1024,
uncompressed_bytes: 4096,
compression: "zstd:1".to_string(),
cohort_bucket,
death_time,
merkle_root: vec![0u8; 32],
created_at: 1_000_000_000_000,
text_index_stripped: false,
}
}
fn make_put_entry(key: &[u8], value: &[u8], lsn: u64, timestamp: i64, ttl: u32) -> MemEntry {
MemEntry {
key: key.to_vec(),
value: Some(value.to_vec()),
op: Operation::Put,
lsn,
timestamp,
ttl,
}
}
fn flush_segment(
dir: &TempDir,
manifest: &mut Manifest,
segment_id: SegmentId,
entries: &[(Vec<u8>, MemEntry)],
cohort_window_secs: u64,
) -> SegmentMeta {
let mut writer =
SegmentWriter::new(dir.path().to_path_buf(), segment_id, cohort_window_secs);
let meta = writer.flush(entries).expect("flush failed");
manifest.add_segment(meta.clone()).expect("add_segment failed");
meta
}
#[test]
fn test_identify_cohorts_groups_and_sorts() {
let now_nanos = 10_000_000_000i64; let segs = vec![
make_segment_meta(0, 1, 3_000_000_000, 5), make_segment_meta(1, 1, 5_000_000_000, 5), make_segment_meta(2, 2, 20_000_000_000, 10), ];
let cohorts = Compactor::identify_cohorts(&segs, now_nanos);
assert_eq!(cohorts.len(), 2);
assert!(cohorts[0].is_fully_expired);
assert_eq!(cohorts[0].cohort_bucket, 1);
assert_eq!(cohorts[0].segment_ids.len(), 2);
assert_eq!(cohorts[0].max_death_time_nanos, 5_000_000_000);
assert_eq!(cohorts[0].total_records, 10);
assert!(!cohorts[1].is_fully_expired);
assert_eq!(cohorts[1].cohort_bucket, 2);
assert_eq!(cohorts[1].segment_ids.len(), 1);
}
#[test]
fn test_identify_cohorts_empty() {
let cohorts = Compactor::identify_cohorts(&[], 1_000_000_000);
assert!(cohorts.is_empty());
}
#[test]
fn test_collect_expired_cohort_removes_files() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let entries = vec![(
encode_key(b"ns", b"key1"),
make_put_entry(b"key1", b"val1", 1, write_time_nanos, 1),
)];
let meta = flush_segment(&dir, &mut manifest, 0, &entries, cohort_window_secs);
let seg_id = meta.segment_id;
for ext in ["dat", "idx", "xf", "meta"] {
assert!(
dir.path().join(format!("segment-{:08}.{}", seg_id, ext)).exists(),
"expected {} to exist before collect",
ext
);
}
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let cohort = CohortInfo {
cohort_bucket: meta.cohort_bucket,
segment_ids: vec![seg_id],
max_death_time_nanos: meta.death_time,
total_records: meta.record_count,
dead_record_estimate: 0,
is_fully_expired: true,
};
let mut stats = CompactionStats::default();
compactor
.collect_expired_cohort(&mut manifest, &cohort, &mut stats)
.unwrap();
for ext in ["dat", "idx", "xf", "meta"] {
assert!(
!dir.path().join(format!("segment-{:08}.{}", seg_id, ext)).exists(),
"expected {} to be deleted after collect",
ext
);
}
assert!(
manifest.list_segments().is_empty(),
"manifest should be empty after collect_expired_cohort"
);
assert_eq!(stats.segments_removed, 1);
assert_eq!(stats.cohorts_collected, 1);
assert_eq!(stats.live_records_relocated, 0, "zero live relocation invariant violated");
}
#[test]
fn test_collect_expired_cohort_missing_file_ok() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let entries = vec![(
encode_key(b"ns", b"key1"),
make_put_entry(b"key1", b"val1", 1, write_time_nanos, 1),
)];
let meta = flush_segment(&dir, &mut manifest, 0, &entries, cohort_window_secs);
let seg_id = meta.segment_id;
std::fs::remove_file(dir.path().join(format!("segment-{:08}.xf", seg_id))).unwrap();
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let cohort = CohortInfo {
cohort_bucket: meta.cohort_bucket,
segment_ids: vec![seg_id],
max_death_time_nanos: meta.death_time,
total_records: meta.record_count,
dead_record_estimate: 0,
is_fully_expired: true,
};
let mut stats = CompactionStats::default();
compactor
.collect_expired_cohort(&mut manifest, &cohort, &mut stats)
.unwrap();
assert_eq!(stats.segments_removed, 1);
}
#[test]
fn test_compact_partial_cohort_removes_dead_entries() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let now_nanos: i64 = write_time_nanos + 2_000_000_000;
let entries_0 = vec![
(
encode_key(b"ns", b"dead-a"),
make_put_entry(b"dead-a", b"val", 1, write_time_nanos, 1),
),
(
encode_key(b"ns", b"dead-b"),
make_put_entry(b"dead-b", b"val", 2, write_time_nanos, 1),
),
(
encode_key(b"ns", b"alive-a"),
make_put_entry(b"alive-a", b"val", 3, write_time_nanos, 0),
),
];
let entries_1 = vec![
(
encode_key(b"ns", b"dead-c"),
make_put_entry(b"dead-c", b"val", 4, write_time_nanos, 1),
),
(
encode_key(b"ns", b"dead-d"),
make_put_entry(b"dead-d", b"val", 5, write_time_nanos, 1),
),
(
encode_key(b"ns", b"alive-b"),
make_put_entry(b"alive-b", b"val", 6, write_time_nanos, 0),
),
];
let meta0 = flush_segment(&dir, &mut manifest, 0, &entries_0, cohort_window_secs);
let meta1 = flush_segment(&dir, &mut manifest, 1, &entries_1, cohort_window_secs);
let cohort = CohortInfo {
cohort_bucket: meta0.cohort_bucket,
segment_ids: vec![0, 1],
max_death_time_nanos: meta0.death_time.max(meta1.death_time),
total_records: 6,
dead_record_estimate: 0,
is_fully_expired: false,
};
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let mut stats = CompactionStats::default();
compactor
.compact_partial_cohort(&mut manifest, &cohort, now_nanos, 2, &mut stats)
.unwrap();
for seg_id in [0u64, 1u64] {
for ext in ["dat", "idx", "xf", "meta"] {
assert!(
!dir.path().join(format!("segment-{:08}.{}", seg_id, ext)).exists(),
"old segment-{} .{} should be removed",
seg_id,
ext
);
}
}
assert!(
dir.path().join("segment-00000002.dat").exists(),
"new output segment should exist"
);
assert_eq!(
manifest.list_segments().len(),
1,
"manifest should have exactly 1 segment after compact_partial_cohort"
);
assert_eq!(manifest.list_segments()[0].segment_id, 2);
assert_eq!(stats.live_records_relocated, 2);
assert_eq!(stats.segments_written, 1);
assert_eq!(stats.segments_removed, 2);
assert_eq!(stats.cohorts_collected, 1);
}
#[test]
fn test_compact_cycle_respects_budget() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let now_nanos: i64 = i64::MAX;
let mut seg_id = 0u64;
for cohort_bucket in 0i64..3 {
for _seg_in_cohort in 0..3 {
let write_time_nanos: i64 = cohort_bucket * 3_600_000_000_000;
let entries = vec![(
encode_key(b"ns", format!("key-{}", seg_id).as_bytes()),
make_put_entry(
format!("key-{}", seg_id).as_bytes(),
b"val",
seg_id + 1,
write_time_nanos,
1, ),
)];
flush_segment(&dir, &mut manifest, seg_id, &entries, cohort_window_secs);
seg_id += 1;
}
}
assert_eq!(
manifest.list_segments().len(),
9,
"should start with 9 segments"
);
let compactor = Compactor::new(dir.path().to_path_buf(), 0, cohort_window_secs);
let pinned: HashSet<SegmentId> = HashSet::new();
let stats = compactor.compact_cycle(&mut manifest, now_nanos, &pinned).unwrap();
assert!(
stats.cohorts_collected <= 1,
"with budget=0, at most 1 cohort should be processed, got {}",
stats.cohorts_collected
);
let remaining = manifest.list_segments().len();
assert!(
remaining >= 6,
"at least 6 segments should remain with budget=0, got {}",
remaining
);
}
#[test]
fn test_compact_cycle_pinned_segments_never_removed() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let now_nanos: i64 = i64::MAX;
let entries = vec![(
encode_key(b"ns", b"pinned-key"),
make_put_entry(b"pinned-key", b"val", 1, write_time_nanos, 1),
)];
let meta = flush_segment(&dir, &mut manifest, 0, &entries, cohort_window_secs);
let pinned_id = meta.segment_id;
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let mut pinned: HashSet<SegmentId> = HashSet::new();
pinned.insert(pinned_id);
let stats = compactor
.compact_cycle(&mut manifest, now_nanos, &pinned)
.unwrap();
assert_eq!(
manifest.list_segments().len(),
1,
"pinned segment should remain in manifest"
);
assert_eq!(
stats.segments_removed, 0,
"no segments should be removed when all are pinned"
);
for ext in ["dat", "idx", "xf", "meta"] {
assert!(
dir.path()
.join(format!("segment-{:08}.{}", pinned_id, ext))
.exists(),
"pinned segment file .{} should still exist",
ext
);
}
}
#[test]
fn test_compact_cycle_full_expiry_removes_all() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let now_nanos: i64 = i64::MAX;
for seg_id in 0..2u64 {
let key = format!("key-{}", seg_id);
let entries = vec![(
encode_key(b"ns", key.as_bytes()),
make_put_entry(key.as_bytes(), b"val", seg_id + 1, write_time_nanos, 1),
)];
flush_segment(&dir, &mut manifest, seg_id, &entries, cohort_window_secs);
}
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let pinned: HashSet<SegmentId> = HashSet::new();
let stats = compactor.compact_cycle(&mut manifest, now_nanos, &pinned).unwrap();
assert!(manifest.list_segments().is_empty(), "all segments should be removed");
assert_eq!(stats.segments_removed, 2);
assert_eq!(stats.cohorts_collected, 1);
assert_eq!(stats.live_records_relocated, 0);
}
#[test]
fn test_collect_expired_cohort_manifest_consistent_after_removal() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let entries = vec![(
encode_key(b"ns", b"k"),
make_put_entry(b"k", b"v", 1, write_time_nanos, 1),
)];
let meta = flush_segment(&dir, &mut manifest, 0, &entries, cohort_window_secs);
let seg_id = meta.segment_id;
assert_eq!(manifest.list_segments().len(), 1);
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let cohort = CohortInfo {
cohort_bucket: meta.cohort_bucket,
segment_ids: vec![seg_id],
max_death_time_nanos: meta.death_time,
total_records: meta.record_count,
dead_record_estimate: meta.record_count,
is_fully_expired: true,
};
let mut stats = CompactionStats::default();
compactor.collect_expired_cohort(&mut manifest, &cohort, &mut stats).unwrap();
assert!(manifest.list_segments().is_empty(), "manifest must not list removed segment");
let manifest2 = Manifest::open(&manifest_path).unwrap();
assert!(manifest2.list_segments().is_empty(), "reloaded manifest must also be empty");
}
#[test]
fn test_compact_partial_cohort_all_dead_manifest_consistent() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("MANIFEST");
let mut manifest = Manifest::open(&manifest_path).unwrap();
let cohort_window_secs = 3600u64;
let write_time_nanos: i64 = 3_600_000_000_000;
let entries = vec![(
encode_key(b"ns", b"gone"),
make_put_entry(b"gone", b"val", 1, write_time_nanos, 1),
)];
let meta = flush_segment(&dir, &mut manifest, 0, &entries, cohort_window_secs);
let seg_id = meta.segment_id;
let compactor = Compactor::new(dir.path().to_path_buf(), u64::MAX, cohort_window_secs);
let cohort = CohortInfo {
cohort_bucket: meta.cohort_bucket,
segment_ids: vec![seg_id],
max_death_time_nanos: meta.death_time,
total_records: meta.record_count,
dead_record_estimate: meta.record_count,
is_fully_expired: false,
};
let now_nanos: i64 = 7_200_000_000_000; let mut stats = CompactionStats::default();
compactor.compact_partial_cohort(&mut manifest, &cohort, now_nanos, 1, &mut stats).unwrap();
assert!(manifest.list_segments().is_empty(), "manifest must not list removed segment");
let manifest2 = Manifest::open(&manifest_path).unwrap();
assert!(manifest2.list_segments().is_empty(), "reloaded manifest must also be empty");
}
}