use super::{Choice, CompactionStrategy, Input as CompactionInput};
use crate::{
KvPair, TableId, compaction::state::CompactionState, config::Config, table::Table,
version::Version,
};
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[cfg(test)]
mod tests;
#[doc(hidden)]
pub const NAME: &str = "SizeTieredCompaction";
#[derive(Clone)]
pub struct Strategy {
size_ratio: f64,
min_merge_width: usize,
max_merge_width: usize,
max_space_amplification_percent: u64,
target_size: u64,
}
impl Default for Strategy {
fn default() -> Self {
Self {
size_ratio: 1.0,
min_merge_width: 4,
max_merge_width: usize::MAX,
max_space_amplification_percent: 200,
target_size: 64 * 1_024 * 1_024,
}
}
}
impl Strategy {
#[must_use]
pub fn with_size_ratio(mut self, ratio: f64) -> Self {
self.size_ratio = if ratio.is_finite() && ratio >= 0.0 {
ratio
} else {
1.0
};
self
}
#[must_use]
pub fn with_min_merge_width(mut self, width: usize) -> Self {
self.min_merge_width = width.max(2);
self
}
#[must_use]
pub fn with_max_merge_width(mut self, width: usize) -> Self {
self.max_merge_width = width.max(2);
self
}
#[must_use]
pub fn with_max_space_amplification_percent(mut self, percent: u64) -> Self {
self.max_space_amplification_percent = percent;
self
}
#[must_use]
pub fn with_table_target_size(mut self, bytes: u64) -> Self {
self.target_size = bytes;
self
}
}
struct RunInfo {
size: u64,
table_ids: Vec<TableId>,
}
fn collect_runs(version: &Version, state: &CompactionState) -> Vec<Option<RunInfo>> {
version
.l0()
.iter()
.map(|run| {
if run
.iter()
.any(|table| state.hidden_set().is_hidden(table.id()))
{
return None;
}
let size = run.iter().map(Table::file_size).sum::<u64>();
let table_ids = run.iter().map(Table::id).collect();
Some(RunInfo { size, table_ids })
})
.collect()
}
fn similar(a: &RunInfo, b: &RunInfo, size_ratio: f64) -> bool {
let (smaller, larger) = if a.size <= b.size {
(a.size, b.size)
} else {
(b.size, a.size)
};
if smaller == 0 {
return true;
}
#[expect(
clippy::cast_precision_loss,
reason = "precision loss is acceptable for ratio comparison"
)]
let ratio = larger as f64 / smaller as f64;
ratio <= 1.0 + size_ratio
}
fn frontmost_overlap_ahead(
version: &Version,
cmp: &dyn crate::comparator::UserComparator,
) -> Vec<Option<usize>> {
let l0 = version.l0();
l0.iter()
.enumerate()
.map(|(at, run)| {
l0.iter().take(at).position(|ahead| {
ahead.iter().any(|table| {
!run.get_overlapping_cmp(&table.metadata.key_range, cmp)
.is_empty()
})
})
})
.collect()
}
fn clear_of_runs_ahead(ahead: &[Option<usize>], run: usize, start: usize) -> bool {
ahead
.get(run)
.copied()
.flatten()
.is_none_or(|overlap| overlap >= start)
}
fn merge_runs<'a>(runs: impl Iterator<Item = &'a RunInfo>, target_size: u64) -> Choice {
Choice::Merge(CompactionInput {
table_ids: runs.flat_map(|r| r.table_ids.iter().copied()).collect(),
dest_level: 0,
canonical_level: 0,
target_size,
})
}
impl CompactionStrategy for Strategy {
fn get_name(&self) -> &'static str {
NAME
}
fn get_config(&self) -> Vec<KvPair> {
use crate::io::{LittleEndian, WriteBytesExt};
let mut size_ratio_bytes = vec![];
#[expect(clippy::expect_used, reason = "writing into Vec should not fail")]
size_ratio_bytes
.write_f64::<LittleEndian>(self.size_ratio)
.expect("cannot fail");
vec![
(
crate::UserKey::from("tiered_size_ratio"),
crate::UserValue::from(size_ratio_bytes),
),
(
crate::UserKey::from("tiered_min_merge_width"),
crate::UserValue::from(
#[expect(
clippy::cast_possible_truncation,
reason = "min_merge_width fits in u32 for persistence; usize::MAX maps to u32::MAX"
)]
(self.min_merge_width.min(u32::MAX as usize) as u32).to_le_bytes(),
),
),
(
crate::UserKey::from("tiered_max_merge_width"),
crate::UserValue::from(
#[expect(
clippy::cast_possible_truncation,
reason = "max_merge_width fits in u32 for persistence; usize::MAX maps to u32::MAX"
)]
(self.max_merge_width.min(u32::MAX as usize) as u32).to_le_bytes(),
),
),
(
crate::UserKey::from("tiered_max_space_amp_pct"),
crate::UserValue::from(self.max_space_amplification_percent.to_le_bytes()),
),
(
crate::UserKey::from("tiered_target_size"),
crate::UserValue::from(self.target_size.to_le_bytes()),
),
]
}
fn next_merge_level(&self, _version: &Version, _dest_level: u8) -> Option<usize> {
None
}
fn pending_compaction_bytes(&self, version: &Version) -> u64 {
let l0 = version.l0();
let mut total: u128 = 0;
let mut largest: u128 = 0;
let mut run_count = 0usize;
for run in l0.iter() {
let size: u128 = run.iter().map(|t| u128::from(Table::file_size(t))).sum();
total += size;
largest = largest.max(size);
run_count += 1;
}
if run_count < 2 || largest == 0 {
return 0;
}
let lhs = total * 100;
let rhs = largest.saturating_mul(100 + u128::from(self.max_space_amplification_percent));
let debt = lhs.saturating_sub(rhs) / 100;
u64::try_from(debt).unwrap_or(u64::MAX)
}
fn choose(&self, version: &Version, config: &Config, state: &CompactionState) -> Choice {
let cmp = config.comparator.as_ref();
let all_runs = collect_runs(version, state);
let runs: Vec<&RunInfo> = all_runs.iter().flatten().collect();
if runs.len() < 2 {
return Choice::DoNothing;
}
let ahead = frontmost_overlap_ahead(version, cmp);
let first_available = all_runs.iter().position(Option::is_some);
let last_available = all_runs.iter().rposition(Option::is_some);
let available_contiguous = match (first_available, last_available) {
(Some(first), Some(last)) => all_runs.get(first..=last).is_some_and(|span| {
span.iter().all(Option::is_some)
&& (first..=last).all(|run| clear_of_runs_ahead(&ahead, run, first))
}),
_ => false,
};
let total_size: u64 = runs.iter().map(|r| r.size).sum();
let largest_run_size = runs.iter().map(|r| r.size).max().unwrap_or(0);
if largest_run_size > 0 && available_contiguous {
let lhs = u128::from(total_size) * 100;
let rhs = u128::from(largest_run_size)
.saturating_mul(100 + u128::from(self.max_space_amplification_percent));
if lhs >= rhs {
return merge_runs(runs.iter().copied(), self.target_size);
}
}
let merge_count_for = |stretch: usize| {
let count = stretch.min(self.max_merge_width);
(count >= self.min_merge_width).then_some(count)
};
let mut start = 0;
while start < all_runs.len() {
let mut len = 0;
if let Some(Some(first)) = all_runs.get(start)
&& clear_of_runs_ahead(&ahead, start, start)
{
let mut prev = first;
len = 1;
while let Some(Some(next)) = all_runs.get(start + len)
&& similar(prev, next, self.size_ratio)
&& clear_of_runs_ahead(&ahead, start + len, start)
{
prev = next;
len += 1;
}
if let Some(count) = merge_count_for(len) {
return merge_runs(
all_runs.iter().skip(start).take(count).flatten(),
self.target_size,
);
}
}
start += len.max(1);
}
Choice::DoNothing
}
}