pub mod dp;
use super::{Choice, CompactionStrategy, Input as CompactionInput};
use crate::compaction::state::CompactionState;
use crate::config::Config;
use crate::table::Table;
use crate::version::Version;
use crate::{HashSet, TableId};
use alloc::sync::Arc;
use alloc::vec::Vec;
use portable_atomic::{AtomicU64, Ordering};
#[cfg(feature = "std")]
use parking_lot::Mutex;
#[cfg(not(feature = "std"))]
use spin::Mutex;
#[doc(hidden)]
pub const NAME: &str = "PlannedCompaction";
pub const DEFAULT_ROUND: u32 = 10;
const DRAIN: usize = 2;
#[expect(clippy::cast_possible_truncation, reason = "a small constant level")]
const DRAIN_LEVEL: u8 = DRAIN as u8;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Model {
pub write_rate: u64,
pub encode_rate: u64,
pub copy_rate: u64,
pub copied_share: f64,
pub merge_query_share: f64,
pub false_positive_rate: f64,
}
impl Default for Model {
fn default() -> Self {
Self {
write_rate: 16 * 1_024 * 1_024,
encode_rate: 256 * 1_024 * 1_024,
copy_rate: 1_024 * 1_024 * 1_024,
copied_share: 0.0,
merge_query_share: 0.5,
false_positive_rate: 0.01,
}
}
}
pub struct Strategy {
round: u32,
size_ratio: u32,
target_size: u64,
l0_file_threshold: u16,
model: Model,
scan_share: Option<f64>,
budget_seen: AtomicU64,
plan: Mutex<Option<(dp::Inputs, Arc<dp::Plan>)>>,
}
impl Default for Strategy {
fn default() -> Self {
Self {
round: DEFAULT_ROUND,
size_ratio: 3,
target_size: 64 * 1_024 * 1_024,
l0_file_threshold: super::leveled::DEFAULT_L0_FILE_THRESHOLD,
model: Model::default(),
scan_share: None,
budget_seen: AtomicU64::new(0),
plan: Mutex::new(None),
}
}
}
impl Strategy {
#[must_use]
pub fn with_round(mut self, units: u32) -> Self {
self.round = units.clamp(1, dp::MAX_ROUND);
self
}
#[must_use]
pub fn with_size_ratio(mut self, ratio: u32) -> Self {
self.size_ratio = ratio.clamp(1, dp::MAX_SIZE_RATIO);
self
}
#[must_use]
pub fn with_table_target_size(mut self, bytes: u64) -> Self {
self.target_size = bytes.max(1);
self
}
#[must_use]
pub fn with_l0_file_threshold(mut self, tables: u16) -> Self {
self.l0_file_threshold = tables.max(1);
self
}
#[must_use]
pub fn with_model(mut self, model: Model) -> Self {
self.model = model;
self
}
#[must_use]
pub fn with_scan_share(mut self, share: Option<f64>) -> Self {
self.scan_share = share.map(|share| share.clamp(0.0, 1.0));
self
}
const fn round(&self) -> u32 {
self.round
}
fn size_ratio(&self, budget: u64) -> u32 {
let paid = within_budget(self.model.encode_rate, budget) / self.model.write_rate.max(1);
let paid = u32::try_from(paid.saturating_sub(2)).unwrap_or(u32::MAX);
paid.clamp(1, self.size_ratio)
}
fn unit(&self, last_bytes: u64, budget: u64) -> u64 {
(last_bytes / (u64::from(self.size_ratio(budget)) * u64::from(self.round())))
.max(self.target_size)
}
fn plan(&self, inputs: dp::Inputs) -> Arc<dp::Plan> {
let mut cached = self.plan.lock();
if let Some((solved_for, plan)) = cached.as_ref()
&& *solved_for == inputs
{
return Arc::clone(plan);
}
let plan = Arc::new(dp::Plan::solve(&inputs));
*cached = Some((inputs, Arc::clone(&plan)));
plan
}
fn inputs(&self, version: &Version, unit: u64, budget: u64) -> dp::Inputs {
let scan_share = self.scan_share.unwrap_or_else(|| {
let (points, scans) = version.read_mix().counts();
#[expect(
clippy::cast_precision_loss,
reason = "a share of read counts, approximate by nature"
)]
let scans = scans as f64;
#[expect(
clippy::cast_precision_loss,
reason = "a share of read counts, approximate by nature"
)]
let points = points as f64;
if scans + points == 0.0 {
0.0
} else {
scans / (scans + points)
}
});
let model = self.model;
let encode_rate = within_budget(model.encode_rate, budget).max(1);
let copy_rate = within_budget(model.copy_rate, budget).max(1);
#[expect(
clippy::cast_precision_loss,
reason = "seconds of a unit, approximate by nature"
)]
let (unit, write_rate, copy_rate, encode_rate) = (
unit as f64,
model.write_rate.max(1) as f64,
copy_rate as f64,
encode_rate as f64,
);
let copied = model.copied_share.clamp(0.0, 1.0);
dp::Inputs {
round: self.round(),
size_ratio: self.size_ratio(budget),
arrival_secs: unit / write_rate,
merge_secs: unit * (1.0 - copied) / encode_rate + unit * copied / copy_rate,
merge_query_share: model.merge_query_share.clamp(0.0, 1.0),
scan_share,
false_positive_rate: model.false_positive_rate.clamp(0.0, 1.0),
}
}
}
fn within_budget(model_rate: u64, budget: u64) -> u64 {
match budget {
0 => model_rate,
budget => model_rate.min(budget),
}
}
fn budget(config: &Config) -> u64 {
config
.live_compaction_rate_limiter
.as_ref()
.or(config.compaction_rate_limiter.as_ref())
.map_or(config.compaction_rate_limit, |limiter| limiter.rate())
}
fn endurance_budget(config: &Config, level: u8, write_rate: u64) -> Option<u64> {
let (_, fs) = config.tables_folder_for_level(level);
let budget = fs.write_budget()?;
let allowance = budget.allowance();
let per_sec = u128::from(allowance.bytes_per_period()) * 1_000_000_000
/ allowance.period().as_nanos().max(1);
let per_sec = u64::try_from(per_sec).unwrap_or(u64::MAX);
let (_, l0_fs) = config.tables_folder_for_level(0);
let flushes_here = l0_fs
.write_budget()
.is_some_and(|l0| Arc::ptr_eq(l0, budget));
if !flushes_here {
return Some(per_sec.max(1));
}
Some(per_sec.saturating_sub(write_rate).max(1))
}
fn tighter(a: u64, b: u64) -> u64 {
match (a, b) {
(0, other) | (other, 0) => other,
(a, b) => a.min(b),
}
}
fn main_runs(main: &crate::version::Level) -> Vec<(Vec<TableId>, u64)> {
main.iter()
.map(|run| {
let bytes: u64 = run.iter().map(Table::file_size).sum();
(run.iter().map(Table::id).collect(), bytes)
})
.collect()
}
impl CompactionStrategy for Strategy {
fn get_name(&self) -> &'static str {
NAME
}
fn pending_compaction_bytes(&self, version: &Version) -> u64 {
use crate::version::Level;
let last = version.level_count() - 1;
if last == 0 {
return 0;
}
let last_bytes = version.level(last).map_or(0, Level::size);
let unit = self.unit(last_bytes, self.budget_seen.load(Ordering::Relaxed));
let top_level = version.level(0);
let top = top_level.map_or(0, Level::size);
let top_owed = if top_level
.is_some_and(|level| level.table_count() >= usize::from(self.l0_file_threshold))
{
top
} else {
top.saturating_sub(unit)
};
if last < DRAIN {
return top_owed;
}
let main = version.level(1).map_or(0, Level::size);
let draining: u64 = (DRAIN..last)
.filter_map(|index| version.level(index))
.map(Level::size)
.sum();
top_owed + main.saturating_sub(unit * u64::from(self.round())) + draining
}
fn next_merge_level(&self, version: &Version, dest_level: u8) -> Option<usize> {
let _ = (version, dest_level);
None
}
fn choose(&self, version: &Version, config: &Config, state: &CompactionState) -> Choice {
let last = version.level_count() - 1;
if last == 0 {
return Choice::DoNothing;
}
let hidden = state.hidden_set();
let busy = |index: usize| version.level_is_busy(index, hidden);
#[expect(
clippy::expect_used,
reason = "levels 0 to the last exist in every version, and the last is past 0"
)]
let (top, main, last_level) = (
version.level(0).expect("L0"),
version.level(1).expect("L1"),
version.level(last).expect("the last level"),
);
#[expect(clippy::cast_possible_truncation, reason = "level index below 255")]
let budget = tighter(
budget(config),
endurance_budget(config, last as u8, self.model.write_rate).unwrap_or(0),
);
self.budget_seen.store(budget, Ordering::Relaxed);
let unit = self.unit(last_level.size(), budget);
let runs = main_runs(main);
let units = runs.iter().map(|(_, bytes)| bytes).sum::<u64>() / unit;
#[expect(clippy::cast_possible_truncation, reason = "level index below 255")]
let last_index = last as u8;
let top_due = !top.is_empty()
&& (top.size() >= unit || top.table_count() >= usize::from(self.l0_file_threshold));
if last < DRAIN {
if top_due && !busy(0) && !busy(1) {
let mut table_ids = top.list_ids();
table_ids.extend(main.list_ids());
return Choice::Merge(CompactionInput {
table_ids,
dest_level: last_index,
canonical_level: last_index,
target_size: self.target_size,
});
}
return Choice::DoNothing;
}
if !busy(0) && !busy(1) && top_due {
let choice = CompactionInput {
table_ids: top.list_ids(),
dest_level: 1,
canonical_level: 1,
target_size: self.target_size,
};
let cmp = config.comparator.as_ref();
let key_range = top.aggregate_key_range_cmp(cmp);
let overlaps = main
.iter()
.any(|run| !run.get_overlapping_cmp(&key_range, cmp).is_empty());
if top.run_count() == 1 && !overlaps {
return Choice::Move(choice);
}
return Choice::Merge(choice);
}
let draining: HashSet<TableId> = (DRAIN..last)
.filter_map(|index| version.level(index))
.flat_map(crate::version::Level::list_ids)
.collect();
if draining.is_empty() {
if units >= u64::from(self.round()) && !busy(1) && !busy(DRAIN) {
if last == DRAIN {
let mut table_ids = main.list_ids();
table_ids.extend(last_level.list_ids());
return Choice::Merge(CompactionInput {
table_ids,
dest_level: last_index,
canonical_level: last_index,
target_size: self.target_size,
});
}
return Choice::Move(CompactionInput {
table_ids: main.list_ids(),
dest_level: DRAIN_LEVEL,
canonical_level: DRAIN_LEVEL,
target_size: self.target_size,
});
}
} else if (DRAIN..=last).all(|index| !busy(index)) {
let mut table_ids = draining;
table_ids.extend(last_level.list_ids());
return Choice::Merge(CompactionInput {
table_ids,
dest_level: last_index,
canonical_level: last_index,
target_size: self.target_size,
});
}
if busy(1) {
return Choice::DoNothing;
}
let plan = self.plan(self.inputs(version, unit, budget));
let wanted = u64::from(plan.merge_after(u32::try_from(units).unwrap_or(u32::MAX)));
if wanted > 1 {
let wanted_bytes = wanted * unit;
let mut table_ids = HashSet::default();
let (mut merged_bytes, mut merged_runs) = (0u64, 0usize);
for (ids, run_bytes) in &runs {
if merged_bytes >= wanted_bytes {
break;
}
table_ids.extend(ids.iter().copied());
merged_bytes += run_bytes;
merged_runs += 1;
}
if merged_runs > 1 {
return Choice::Merge(CompactionInput {
table_ids,
dest_level: 1,
canonical_level: 1,
target_size: self.target_size,
});
}
}
Choice::DoNothing
}
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "test code")]
mod tests;