use super::stream::RangeTombstoneUse;
use super::{CompactionAction, CompactionResult, CompactionStrategy, Input as CompactionPayload};
use crate::time::Instant;
use crate::tree::inner::{CompactionGuard, VersionsReadGuard};
use crate::{
BlobFile, Config, HashSet, InternalValue, SeqNo, SequenceNumberCounter,
SharedSequenceNumberGenerator, Table, TableId, UserKey,
compaction::{
Choice,
filter::{Context, FilterBlobOutput, StreamFilterAdapter, TransformCounters},
flavour::{FreshValues, RelocatingCompaction, StandardCompaction},
state::CompactionState,
stream::CompactionStream,
},
file::BLOBS_FOLDER,
merge::Merger,
run_scanner::RunScanner,
stop_signal::StopSignal,
tree::inner::TreeId,
version::{Run, SuperVersions, Version},
vlog::{BlobFileMergeScanner, BlobFileScanner, BlobFileWriter},
};
#[cfg(feature = "std")]
use crate::vlog::BlobFileId;
use alloc::sync::Arc;
#[cfg(not(feature = "std"))]
use alloc::{boxed::Box, vec::Vec};
#[cfg(feature = "std")]
use parking_lot::{Mutex, RwLock};
#[cfg(not(feature = "std"))]
use spin::{Mutex, RwLock};
#[cfg(feature = "metrics")]
use crate::metrics::Metrics;
pub type CompactionReader<'a> = Box<dyn Iterator<Item = crate::Result<InternalValue>> + 'a>;
#[cfg(feature = "std")]
pub const SUBCOMPACTION_MIN_INPUT_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Clone)]
pub struct Options {
pub tree_id: TreeId,
pub global_seqno: SharedSequenceNumberGenerator,
pub visible_seqno: SharedSequenceNumberGenerator,
pub(crate) table_files: Arc<crate::table::file_pool::TableFilePools>,
pub blob_file_id_generator: SequenceNumberCounter,
pub config: Arc<Config>,
pub version_history: Arc<RwLock<SuperVersions>>,
pub strategy: Arc<dyn CompactionStrategy>,
pub stop_signal: StopSignal,
pub gc_watermark: crate::SeqNo,
pub compaction_state: Arc<Mutex<CompactionState>>,
pub deletion_pause: Arc<crate::deletion_pause::DeletionPause>,
pub heal_hints: Arc<crate::heal_hints::HealHints>,
#[cfg(feature = "std")]
pub background_deleter: Arc<crate::BackgroundDeleter>,
pub filter_budget: Arc<crate::filter_budget::FilterBudget>,
pub runtime_config: Arc<crate::runtime_config::handle::RuntimeConfigHandle>,
pub encryption: Option<Arc<dyn crate::encryption::EncryptionProvider>>,
pub rate_limiter: Arc<crate::rate_limiter::RateLimiter>,
pub(crate) capacity: Arc<crate::CapacityPool>,
pub(crate) outputs: super::output_ledger::OutputLedger,
pub(crate) work: Arc<super::work::WorkLedger>,
#[cfg(feature = "metrics")]
pub metrics: Arc<Metrics>,
}
impl Options {
pub fn from_tree(tree: &crate::Tree, strategy: Arc<dyn CompactionStrategy>) -> Self {
Self {
global_seqno: tree.config.seqno.clone(),
visible_seqno: tree.config.visible_seqno.clone(),
tree_id: tree.id,
table_files: Arc::clone(&tree.table_files),
blob_file_id_generator: tree.blob_file_id_counter.clone(),
config: tree.config.clone(),
version_history: tree.version_history.clone(),
stop_signal: tree.stop_signal.clone(),
strategy,
gc_watermark: 0,
compaction_state: tree.compaction_state.clone(),
deletion_pause: tree.deletion_pause.clone(),
heal_hints: tree.heal_hints.clone(),
#[cfg(feature = "std")]
background_deleter: Arc::clone(&tree.background_deleter),
filter_budget: tree.filter_budget.clone(),
runtime_config: tree.runtime_config.clone(),
encryption: tree.config.encryption.clone(),
rate_limiter: Arc::clone(&tree.compaction_rate_limiter),
capacity: Arc::clone(&tree.compaction_capacity),
outputs: super::output_ledger::OutputLedger::default(),
work: Arc::default(),
#[cfg(feature = "metrics")]
metrics: tree.metrics.clone(),
}
}
}
pub fn do_compaction(opts: &Options) -> crate::Result<CompactionResult> {
let opts = &Options {
outputs: super::output_ledger::OutputLedger::default(),
work: Arc::default(),
..opts.clone()
};
remove_superseded_outputs(opts);
let result = run_compaction(opts);
let result = match result {
Err(e) if opts.outputs.out_of_room() => {
log::info!("Compaction stopped: its outputs outgrew the room left for them ({e})");
Ok(CompactionResult::nothing())
}
result => result,
};
if matches!(result, Err(crate::Error::InstallOutcomeUnknown(_))) {
let kept = opts.outputs.keep_uninstalled();
opts.version_history.write().keep_outputs(kept);
} else {
opts.outputs
.remove_uninstalled(opts.tree_id, opts.config.descriptor_table.as_deref());
}
remove_superseded_outputs(opts);
let work = opts.work.report();
#[cfg(feature = "metrics")]
opts.metrics.charge_compaction_work(&work);
let mut result = result?;
result.work = work;
Ok(result)
}
fn remove_superseded_outputs(opts: &Options) {
if !opts.version_history.read().holds_kept_outputs() {
return;
}
let superseded = opts.version_history.write().take_superseded_outputs();
for kept in superseded {
kept.remove(opts.tree_id, opts.config.descriptor_table.as_deref());
}
}
fn run_compaction(opts: &Options) -> crate::Result<CompactionResult> {
let compaction_state = opts.compaction_state.lock();
let reclaimed = drop_covered_tables(&compaction_state, opts)?;
run_strategy(compaction_state, opts).map(|result| match reclaimed {
Some(reclaimed) if result.action == CompactionAction::Nothing => reclaimed,
Some(reclaimed) => CompactionResult {
tables_in: result.tables_in + reclaimed.tables_in,
..result
},
None => result,
})
}
fn drop_covered_tables(
compaction_state: &CompactionGuard<'_>,
opts: &Options,
) -> crate::Result<Option<CompactionResult>> {
let version = opts
.version_history
.read()
.latest_version_ref()
.version
.clone();
let covered = super::covered::covered_tables(
&version,
opts.gc_watermark,
compaction_state.hidden_set(),
opts.config.comparator.as_ref(),
);
let Some((ids, effect)) = covered else {
return Ok(None);
};
drop_tables(compaction_state, opts, &ids, effect).map(Some)
}
fn run_strategy(
mut compaction_state: CompactionGuard<'_>,
opts: &Options,
) -> crate::Result<CompactionResult> {
let version_history_lock = opts.version_history.read();
let latest = version_history_lock.latest_version_ref();
compaction_state.set_memtable_blob_files(if latest.version.blob_files.len() > 0 {
latest.memtable_blob_files()
} else {
crate::HashSet::default()
});
let start = Instant::now();
log::trace!(
"Consulting compaction strategy {:?}",
opts.strategy.get_name(),
);
let choice = super::choose_with_density_rewrite(
&*opts.strategy,
&version_history_lock.latest_version_ref().version,
&opts.config,
&opts.runtime_config.load(),
&compaction_state,
);
log::debug!("Compaction choice: {choice:?} in {:?}", start.elapsed());
match choice {
Choice::Merge(payload) => {
gated_merge(compaction_state, version_history_lock, opts, &payload)
}
Choice::Move(payload) => {
if !nothing_the_move_passes_under_overlaps(
&version_history_lock.latest_version_ref().version,
&payload,
opts.config.comparator.as_ref(),
) {
log::warn!(
"Compaction task created by {:?} would move tables beneath an overlapping table, hiding the versions it moved - declining to run it",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
}
if opts.config.level_routes.is_some() {
let (dst_folder, _) = opts.config.tables_folder_for_level(payload.dest_level);
let version = &version_history_lock.latest_version_ref().version;
let cross_folder = version
.iter_levels()
.flat_map(|level| level.iter())
.flat_map(|run| run.iter())
.filter(|t| payload.table_ids.contains(&t.id()))
.any(|t| t.path.parent() != Some(dst_folder.as_path()));
if cross_folder {
log::debug!("Converting trivial move to merge: cross-folder level routing");
return gated_merge(compaction_state, version_history_lock, opts, &payload);
}
}
if moves_into_locality_relocation(
&version_history_lock.latest_version_ref().version,
opts,
&payload,
)? {
log::debug!("Converting trivial move to merge: blob locality relocation");
return gated_merge(compaction_state, version_history_lock, opts, &payload);
}
drop(version_history_lock);
move_tables(&compaction_state, opts, &payload)
}
Choice::Drop(payload) => {
drop(version_history_lock);
let ids = payload.into_iter().collect::<Vec<_>>();
drop_tables(
&compaction_state,
opts,
&ids,
crate::version::RetentionEffect::DropsData,
)
}
Choice::DoNothing => {
log::trace!("Compactor chose to do nothing");
Ok(CompactionResult::nothing())
}
}
}
enum SpaceGate {
Run(super::capacity::Reservation, (u64, u64)),
Narrowed(CompactionPayload, super::capacity::Reservation, (u64, u64)),
Skip((u64, u64)),
}
fn gated_merge(
compaction_state: CompactionGuard<'_>,
version_history_lock: VersionsReadGuard<'_>,
opts: &Options,
payload: &CompactionPayload,
) -> crate::Result<CompactionResult> {
let tight_space = tight_space_enabled(opts);
let decision = {
let super_version = version_history_lock.latest_version();
if payload
.table_ids
.iter()
.any(|&id| super_version.version.get_table(id).is_none())
{
log::warn!(
"Compaction task created by {:?} contained tables not referenced in the level manifest",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
}
let decision = space_gate_for_merge(&super_version.version, opts, payload)?;
let budget = match &decision {
SpaceGate::Run(_, bytes) => budget_gate(
&super_version.version,
opts,
payload.dest_level,
*bytes,
Narrowing::None,
),
SpaceGate::Narrowed(narrowed, _, bytes) => budget_gate(
&super_version.version,
opts,
narrowed.dest_level,
*bytes,
Narrowing::ForSpace,
),
SpaceGate::Skip(bytes) if tight_space => budget_gate(
&super_version.version,
opts,
payload.dest_level,
*bytes,
Narrowing::ForSpace,
),
SpaceGate::Skip(_) => BudgetGate::Run(Vec::new()),
};
let BudgetGate::Run(holds) = budget else {
log::debug!(
"Compaction write budget: {}-table merge waits for credit",
payload.table_ids.len(),
);
return Ok(CompactionResult::nothing());
};
opts.outputs.hold_budget(holds);
decision
};
match decision {
SpaceGate::Run(room, _) => {
opts.outputs.reserve(room);
merge_tables(compaction_state, version_history_lock, opts, payload)
}
SpaceGate::Narrowed(narrowed, room, _) => {
log::debug!(
"Compaction space gate: narrowed merge from {} to {} tables to fit free space",
payload.table_ids.len(),
narrowed.table_ids.len(),
);
opts.outputs.reserve(room);
merge_tables(compaction_state, version_history_lock, opts, &narrowed)
}
SpaceGate::Skip(_) => {
#[cfg(feature = "std")]
if tight_space {
return run_tight_space_compaction(
compaction_state,
version_history_lock,
opts,
payload,
);
}
log::info!(
"Compaction space gate: skipping {}-table merge — free space cannot cover the transient output and no fitting subset exists (opt-in tight-space reclaim handles this)",
payload.table_ids.len(),
);
Ok(CompactionResult::nothing())
}
}
}
fn tight_space_enabled(opts: &Options) -> bool {
cfg!(feature = "std") && opts.runtime_config.load().tight_space_compaction
}
enum BudgetGate {
Run(Vec<crate::write_budget::BudgetHold>),
Wait,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Narrowing {
None,
ForSpace,
}
fn budget_gate(
version: &Version,
opts: &Options,
dest_level: u8,
(sst_bytes, blob_bytes): (u64, u64),
narrowing: Narrowing,
) -> BudgetGate {
use crate::write_budget::Override;
let (_, sst_fs) = opts.config.tables_folder_for_level(dest_level);
let may_write_blobs = blob_bytes > 0 || opts.config.kv_separation_opts.is_some();
let mut demands: Vec<(&Arc<crate::write_budget::WriteBudget>, u64)> = Vec::new();
for (fs, bytes, writes) in [
(&*sst_fs, sst_bytes, sst_bytes > 0),
(&*opts.config.fs, blob_bytes, may_write_blobs),
] {
let Some(budget) = fs.write_budget() else {
continue;
};
if !writes {
continue;
}
if let Some(demand) = demands.iter_mut().find(|(b, _)| Arc::ptr_eq(b, budget)) {
demand.1 += bytes;
} else {
demands.push((budget, bytes));
}
}
let mut holds = Vec::with_capacity(demands.len());
let mut stopped = None;
for (budget, bytes) in demands {
let hold = budget.reserve_or_override(bytes, || {
if budget.forced() {
Some(Override::Forced)
} else if narrowing == Narrowing::ForSpace {
Some(Override::Space)
} else if *stopped.get_or_insert_with(|| at_compaction_stop(version, opts)) {
Some(Override::Backpressure)
} else {
None
}
});
let Some(hold) = hold else {
return BudgetGate::Wait;
};
holds.push(hold);
}
BudgetGate::Run(holds)
}
fn at_compaction_stop(version: &Version, opts: &Options) -> bool {
let thresholds = crate::BackpressureThresholds {
memtable_slowdown: None,
memtable_stop: None,
..opts.runtime_config.load().backpressure
};
if thresholds.is_off() {
return false;
}
let signals = crate::BackpressureSignals {
l0_runs: version.l0().run_count(),
pending_compaction_bytes: opts.strategy.pending_compaction_bytes(version),
unflushed_memtable_bytes: 0,
};
crate::Backpressure::compute(&signals, &thresholds) == crate::Backpressure::Stop
}
fn separate_volumes(
a_fs: &dyn crate::fs::Fs,
a_path: &crate::path::Path,
b_fs: &dyn crate::fs::Fs,
b_path: &crate::path::Path,
) -> bool {
match (a_fs.volume_id(a_path), b_fs.volume_id(b_path)) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
fn with_merge_demands<T>(
config: &Config,
dest_level: u8,
(sst_bytes, blob_bytes): (u64, u64),
f: impl FnOnce(&[super::capacity::Demand<'_>]) -> T,
) -> T {
let (sst_path, sst_fs) = config.tables_folder_for_level(dest_level);
let blob_dir = config.path.join(BLOBS_FOLDER);
f(&[
super::capacity::Demand {
fs: &*sst_fs,
path: &sst_path,
bytes: sst_bytes,
},
super::capacity::Demand {
fs: &*config.fs,
path: &blob_dir,
bytes: blob_bytes,
},
])
}
pub fn merge_would_fit(
pool: &crate::CapacityPool,
tree: TreeId,
config: &Config,
quota_headroom: u64,
(sst_bytes, blob_bytes): (u64, u64),
dest_level: u8,
) -> bool {
with_merge_demands(config, dest_level, (sst_bytes, blob_bytes), |demands| {
pool.would_fit(
tree,
demands,
super::capacity::Check {
quota_headroom,
floor: super::capacity::Floor::MayConsume,
},
)
})
}
fn space_gate_for_merge(
version: &Version,
opts: &Options,
payload: &CompactionPayload,
) -> crate::Result<SpaceGate> {
let rc = opts.runtime_config.load_full();
let check = if rc.storage_admission_check {
let quota_headroom = match rc.storage_limit_bytes {
Some(limit) => limit.saturating_sub(crate::storage_stats::compute_used_bytes(version)?),
None => u64::MAX,
};
Some(super::capacity::Check {
quota_headroom,
floor: super::capacity::Floor::MayConsume,
})
} else {
None
};
let demand = |p: &CompactionPayload| -> crate::Result<(u64, u64)> {
let sst_sigma: u64 = p
.table_ids
.iter()
.filter_map(|&id| version.get_table(id))
.try_fold(0u64, |acc, t| t.live_file_size().map(|size| acc + size))?;
let blob_sigma: u64 = match &opts.config.kv_separation_opts {
Some(blob_opts) => pick_blob_files_to_rewrite(&p.table_ids, version, blob_opts)?
.iter()
.try_fold(0u64, |acc, bf| bf.physical_size().map(|size| acc + size))?,
None => 0,
};
Ok((sst_sigma, blob_sigma))
};
let reserve = |bytes: (u64, u64), dest_level: u8| {
with_merge_demands(&opts.config, dest_level, bytes, |demands| {
opts.capacity.reserve(opts.tree_id, demands, check)
})
};
let chosen = demand(payload)?;
if let Some(room) = reserve(chosen, payload.dest_level) {
return Ok(SpaceGate::Run(room, chosen));
}
for narrowed in narrow_merge_candidates(version, payload)? {
let bytes = demand(&narrowed)?;
if let Some(room) = reserve(bytes, narrowed.dest_level) {
return Ok(SpaceGate::Narrowed(narrowed, room, bytes));
}
}
Ok(SpaceGate::Skip(chosen))
}
fn narrow_merge_candidates(
version: &Version,
payload: &CompactionPayload,
) -> crate::Result<Vec<CompactionPayload>> {
let Some(run) = version
.iter_levels()
.flat_map(|level| level.iter())
.find(|run| {
payload
.table_ids
.iter()
.all(|id| run.iter().any(|t| t.id() == *id))
})
else {
return Ok(Vec::new());
};
let run_tables: Vec<&Table> = run.iter().collect();
let mut candidates: Vec<(u64, CompactionPayload)> = Vec::new();
for pair in run_tables.windows(2) {
let [a, b] = pair else { continue };
if !payload.table_ids.contains(&a.id()) || !payload.table_ids.contains(&b.id()) {
continue;
}
let combined = a.live_file_size()? + b.live_file_size()?;
candidates.push((
combined,
CompactionPayload {
table_ids: [a.id(), b.id()].into_iter().collect(),
dest_level: payload.dest_level,
canonical_level: payload.canonical_level,
target_size: payload.target_size,
},
));
}
candidates.sort_by_key(|(combined, _)| *combined);
Ok(candidates.into_iter().map(|(_, p)| p).collect())
}
fn pick_run_indexes(run: &Run<Table>, to_compact: &[TableId]) -> Option<(usize, usize)> {
let lo = run
.iter()
.position(|table| to_compact.contains(&table.id()))?;
let hi = run
.iter()
.rposition(|table| to_compact.contains(&table.id()))?;
Some((lo, hi))
}
fn input_pacer(opts: &Options) -> crate::table::util::Pacer {
Arc::new(super::work::InputPacer::new(&opts.rate_limiter, &opts.work))
}
fn merge_estimate(
opts: &Options,
version: &Version,
payload: &CompactionPayload,
tables: &[Table],
blob_files: &[BlobFile],
rt_plan: Option<&(
Vec<crate::range_tombstone::RangeTombstone>,
RangeTombstoneUse,
)>,
) -> super::work::WorkEstimate {
#[cfg(feature = "columnar")]
let carry = if blob_files.is_empty() {
super::carry::plan(
&opts.config,
&opts.runtime_config.load(),
payload.canonical_level.into(),
tables,
)
} else {
None
};
#[cfg(not(feature = "columnar"))]
let _ = payload;
let deleting = match rt_plan {
Some((tombstones, RangeTombstoneUse::Delete)) => tombstones.as_slice(),
_ => &[],
};
super::work::estimate_merge(
&super::work::MergePlan {
inputs: tables,
blob_files,
gc_stats: version.gc_stats(),
user_code: opts.config.compaction_filter_factory.is_some()
|| opts.config.merge_operator.is_some(),
range_tombstones: deleting,
comparator: opts.config.comparator.as_ref(),
},
|table| {
#[cfg(feature = "columnar")]
return carry.as_ref().is_some_and(|carry| carry.takes(table));
#[cfg(not(feature = "columnar"))]
{
let _ = table;
false
}
},
)
}
fn create_compaction_stream<'a>(
version: &Version,
to_compact: &[TableId],
gc_watermark: SeqNo,
merge_operator: Option<Arc<dyn crate::merge_operator::MergeOperator>>,
comparator: crate::comparator::SharedComparator,
pace: Option<&crate::table::util::Pacer>,
#[cfg(feature = "columnar")] carry: Option<&crate::table::group_carry::CarryTarget>,
) -> crate::Result<Option<CompactionStream<'a, Merger<CompactionReader<'a>>>>> {
let mut readers: Vec<CompactionReader<'_>> = vec![];
let mut found = 0;
for run in version.iter_levels().flat_map(|lvl| lvl.iter()) {
if run.len() > 1 {
let Some((lo, hi)) = pick_run_indexes(run, to_compact) else {
continue;
};
let scanner = RunScanner::culled(run.clone(), (Some(lo), Some(hi)), pace.cloned())?;
#[cfg(feature = "columnar")]
let scanner = match carry {
Some(target) => scanner.with_carry(target.clone()),
None => scanner,
};
readers.push(Box::new(scanner));
found += hi - lo + 1;
} else {
for table in run.iter().filter(|x| to_compact.contains(&x.metadata.id)) {
found += 1;
let scanner = table.scan_paced(pace)?;
#[cfg(feature = "columnar")]
let scanner = match carry {
Some(target) if target.takes(table) => {
scanner.with_carry(target.carry_for(table))
}
_ => scanner,
};
readers.push(Box::new(scanner));
}
}
}
Ok(if found == to_compact.len() {
Some(
CompactionStream::new(Merger::new(readers, comparator), gc_watermark)
.with_merge_operator(merge_operator),
)
} else {
None
})
}
#[cfg(feature = "std")]
#[cfg_attr(
feature = "columnar",
expect(
clippy::too_many_arguments,
reason = "the serial stream's inputs plus the range and the carry plan"
)
)]
#[cfg_attr(
not(feature = "columnar"),
expect(
clippy::unnecessary_wraps,
reason = "only the columnar build's carrying scan can fail to open"
)
)]
fn create_bounded_compaction_stream<'a>(
version: &'a Version,
to_compact: &HashSet<TableId>,
bounds: (core::ops::Bound<UserKey>, core::ops::Bound<UserKey>),
gc_watermark: SeqNo,
merge_operator: Option<Arc<dyn crate::merge_operator::MergeOperator>>,
comparator: crate::comparator::SharedComparator,
pace: Option<&crate::table::util::Pacer>,
#[cfg(feature = "columnar")] carry: Option<&crate::table::group_carry::CarryTarget>,
) -> crate::Result<Option<CompactionStream<'a, Merger<CompactionReader<'a>>>>> {
let mut readers: Vec<CompactionReader<'_>> = vec![];
let mut found = 0;
for run in version.iter_levels().flat_map(|lvl| lvl.iter()) {
for table in run.iter().filter(|x| to_compact.contains(&x.metadata.id)) {
found += 1;
#[cfg(feature = "columnar")]
if let Some(target) = carry
&& target.takes(table)
{
let scanner = table.scan_carrying(
crate::table::group_carry::KeyBounds {
lo: bounds.0.clone(),
hi: bounds.1.clone(),
comparator: comparator.clone(),
},
Arc::clone(&target.queue),
pace,
)?;
match scanner {
Some(scanner) => readers.push(Box::new(scanner)),
None => readers.push(Box::new(core::iter::empty())),
}
continue;
}
let reader = table.range_iter(bounds.clone()).for_maintenance();
let reader = match pace {
Some(pace) => reader.with_pace(Arc::clone(pace)),
None => reader,
};
readers.push(Box::new(reader));
}
}
Ok((found == to_compact.len()).then(|| {
CompactionStream::new(Merger::new(readers, comparator), gc_watermark)
.with_merge_operator(merge_operator)
}))
}
fn collect_version_tombstones(version: &Version) -> Vec<crate::range_tombstone::RangeTombstone> {
version
.iter_levels()
.flat_map(|level| level.iter())
.flat_map(|run| run.iter())
.flat_map(|t| t.range_tombstones().iter().cloned())
.collect()
}
fn range_tombstone_plan(
version: &Version,
is_last_level: bool,
config: &crate::config::Config,
) -> Option<(
Vec<crate::range_tombstone::RangeTombstone>,
RangeTombstoneUse,
)> {
let use_ = if is_last_level {
RangeTombstoneUse::Delete
} else if composes_operands(config) {
RangeTombstoneUse::Boundary
} else {
return None;
};
Some((collect_version_tombstones(version), use_))
}
fn composes_operands(config: &crate::config::Config) -> bool {
config
.merge_operator
.as_ref()
.is_some_and(|op| op.composes_operands())
}
fn outside_tables<'a>(version: &'a Version, table_ids: &HashSet<TableId>) -> Vec<&'a Table> {
version
.iter_tables()
.filter(|table| !table_ids.contains(&table.id()))
.collect()
}
fn input_completeness_for<'a>(
version: &'a Version,
table_ids: &HashSet<TableId>,
config: &crate::config::Config,
) -> Option<impl Fn(&[u8], SeqNo, SeqNo) -> bool + 'a> {
composes_operands(config).then(|| {
input_completeness_predicate(
outside_tables(version, table_ids),
config.comparator.clone(),
)
})
}
fn input_completeness_predicate(
outside: Vec<&Table>,
comparator: crate::comparator::SharedComparator,
) -> impl Fn(&[u8], SeqNo, SeqNo) -> bool + '_ {
use crate::version::run::Ranged as _;
move |key: &[u8], oldest: SeqNo, newest: SeqNo| {
let cmp = comparator.as_ref();
let key_hash = crate::hash::hash64(key);
!outside.iter().any(|table| {
may_break_chain(
table.seqno_range(),
table.key_range(),
cmp,
key,
(oldest, newest),
|| table.bloom_may_contain_key(key, key_hash).unwrap_or(true),
)
})
}
}
fn may_break_chain(
seqno_range: Option<(SeqNo, SeqNo)>,
key_range: &crate::KeyRange,
comparator: &dyn crate::comparator::UserComparator,
key: &[u8],
(oldest, newest): (SeqNo, SeqNo),
may_contain: impl FnOnce() -> bool,
) -> bool {
let within_seqnos = seqno_range.is_none_or(|(low, high)| high >= oldest && low <= newest);
if !within_seqnos {
return false;
}
let within_keys = comparator.compare(key_range.min(), key) != core::cmp::Ordering::Greater
&& comparator.compare(key_range.max(), key) != core::cmp::Ordering::Less;
within_keys && may_contain()
}
fn zeroing_tombstones_from(
plan: Option<&(
Vec<crate::range_tombstone::RangeTombstone>,
RangeTombstoneUse,
)>,
) -> Vec<crate::range_tombstone::RangeTombstone> {
match plan {
Some((tombstones, RangeTombstoneUse::Delete)) => tombstones.clone(),
_ => Vec::new(),
}
}
fn nothing_the_move_passes_under_overlaps(
version: &Version,
payload: &CompactionPayload,
comparator: &dyn crate::comparator::UserComparator,
) -> bool {
let dest = usize::from(payload.dest_level);
let mut by_source: Vec<(usize, Vec<&Table>)> = Vec::new();
for (source, table) in moved_tables(version, payload) {
match by_source.iter_mut().find(|(level, _)| *level == source) {
Some((_, tables)) => tables.push(table),
None => by_source.push((source, vec![table])),
}
}
for (source, mut tables) in by_source {
sort_by_min_key(&mut tables, comparator);
let span = source.min(dest)..=source.max(dest);
for (idx, level) in version.iter_levels().enumerate() {
if !span.contains(&idx) {
continue;
}
let mut others: Vec<&Table> = level
.iter()
.flat_map(|run| run.iter())
.filter(|other| !payload.table_ids.contains(&other.id()))
.collect();
sort_by_min_key(&mut others, comparator);
if idx == 0 {
if tables.iter().any(|table| {
others.iter().any(|other| {
overlaps(table, other, comparator) && !proven_older(table, other)
})
}) {
return false;
}
} else if any_overlap(&tables, &others, comparator) {
return false;
}
}
}
true
}
fn sort_by_min_key(tables: &mut [&Table], comparator: &dyn crate::comparator::UserComparator) {
use crate::version::run::Ranged as _;
tables.sort_by(|a, b| comparator.compare(a.key_range().min(), b.key_range().min()));
}
fn any_overlap(
left: &[&Table],
right: &[&Table],
comparator: &dyn crate::comparator::UserComparator,
) -> bool {
use crate::version::run::Ranged as _;
let mut l = 0;
let mut r = 0;
while let (Some(a), Some(b)) = (left.get(l), right.get(r)) {
if comparator
.compare(a.key_range().max(), b.key_range().min())
.is_lt()
{
l += 1;
} else if comparator
.compare(b.key_range().max(), a.key_range().min())
.is_lt()
{
r += 1;
} else {
return true;
}
}
false
}
fn moved_tables<'a>(version: &'a Version, payload: &CompactionPayload) -> Vec<(usize, &'a Table)> {
version
.iter_levels()
.enumerate()
.flat_map(|(idx, level)| {
level
.iter()
.flat_map(|run| run.iter())
.map(move |table| (idx, table))
})
.filter(|(_, table)| payload.table_ids.contains(&table.id()))
.collect()
}
fn proven_older(table: &Table, other: &Table) -> bool {
match (table.seqno_range(), other.seqno_range()) {
(Some((_, newest_moved)), Some((oldest_peer, _))) => newest_moved < oldest_peer,
_ => false,
}
}
fn overlaps(a: &Table, b: &Table, comparator: &dyn crate::comparator::UserComparator) -> bool {
use crate::version::run::Ranged as _;
a.key_range()
.overlaps_with_key_range_cmp(b.key_range(), comparator)
}
fn holds_every_surviving_version(
version: &Version,
table_ids: &HashSet<TableId>,
is_last_level: bool,
comparator: &dyn crate::comparator::UserComparator,
) -> bool {
use crate::version::run::Ranged as _;
if !is_last_level {
return false;
}
let covered = crate::KeyRange::aggregate_cmp(
version
.iter_tables()
.filter(|table| table_ids.contains(&table.id()))
.map(Table::key_range),
comparator,
);
version
.iter_tables()
.filter(|table| !table_ids.contains(&table.id()))
.all(|table| {
!table
.key_range()
.overlaps_with_key_range_cmp(&covered, comparator)
})
}
#[cfg(feature = "std")]
fn range_tombstones_after_gc(
input_rts: &[crate::range_tombstone::RangeTombstone],
outside: &OutsideSpans,
watermark: SeqNo,
is_last_level: bool,
comparator: &crate::comparator::SharedComparator,
) -> Vec<crate::range_tombstone::RangeTombstone> {
if !is_last_level {
return input_rts.to_vec();
}
let cmp = comparator.as_ref();
input_rts
.iter()
.filter(|rt| {
!rt.visible_at(watermark) || outside.overlaps(rt, cmp)
})
.cloned()
.collect()
}
pub struct OutsideSpans(Vec<(UserKey, UserKey)>);
impl OutsideSpans {
pub fn new(
version: &Version,
input_ids: &HashSet<TableId>,
cmp: &dyn crate::comparator::UserComparator,
) -> Self {
Self::from_ranges(
version
.iter_levels()
.flat_map(|level| level.iter())
.flat_map(|run| run.iter())
.filter(|t| !input_ids.contains(&t.id()))
.map(|t| {
let kr = &t.metadata.key_range;
(kr.min().clone(), kr.max().clone())
})
.collect(),
cmp,
)
}
fn from_ranges(
mut ranges: Vec<(UserKey, UserKey)>,
cmp: &dyn crate::comparator::UserComparator,
) -> Self {
use core::cmp::Ordering::{Greater, Less};
ranges.sort_by(|a, b| cmp.compare(&a.0, &b.0));
let mut spans: Vec<(UserKey, UserKey)> = Vec::with_capacity(ranges.len());
for (min, max) in ranges {
match spans.last_mut() {
Some((_, end)) if cmp.compare(&min, end) != Greater => {
if cmp.compare(end, &max) == Less {
*end = max;
}
}
_ => spans.push((min, max)),
}
}
Self(spans)
}
fn overlaps(
&self,
rt: &crate::range_tombstone::RangeTombstone,
cmp: &dyn crate::comparator::UserComparator,
) -> bool {
use core::cmp::Ordering::Less;
let first = self
.0
.partition_point(|(_, max)| cmp.compare(max, &rt.start) == Less);
self.0
.get(first)
.is_some_and(|(min, _)| cmp.compare(min, &rt.end) == Less)
}
}
#[cfg(feature = "std")]
fn answered_span(
bounds: &(core::ops::Bound<UserKey>, core::ops::Bound<UserKey>),
) -> (Option<UserKey>, Option<UserKey>) {
use core::ops::Bound;
(
match &bounds.0 {
Bound::Included(key) | Bound::Excluded(key) => Some(key.clone()),
Bound::Unbounded => None,
},
match &bounds.1 {
Bound::Excluded(key) => Some(key.clone()),
Bound::Included(key) => crate::range_tombstone::upper_bound_exclusive(key),
Bound::Unbounded => None,
},
)
}
fn tombstones_outside(
rts: &[crate::range_tombstone::RangeTombstone],
outside: &OutsideSpans,
(lower, upper): (Option<UserKey>, Option<UserKey>),
comparator: &crate::comparator::SharedComparator,
) -> crate::table::multi_writer::TombstonesOutside {
use core::cmp::Ordering::Less;
let cmp = comparator.as_ref();
crate::table::multi_writer::TombstonesOutside {
tombstones: rts
.iter()
.filter(|rt| {
lower
.as_ref()
.is_none_or(|lower| cmp.compare(lower, &rt.end) == Less)
&& upper
.as_ref()
.is_none_or(|upper| cmp.compare(&rt.start, upper) == Less)
&& outside.overlaps(rt, cmp)
})
.cloned()
.collect(),
lower,
upper,
}
}
#[cfg_attr(
not(feature = "std"),
allow(
dead_code,
reason = "parallel sub-compaction boundary picker; the threaded parallel path is std-gated, so unused under no_std"
)
)]
fn boundary_candidates(
mut keys: Vec<UserKey>,
comparator: &crate::comparator::SharedComparator,
) -> Vec<UserKey> {
if keys.len() < 2 {
return Vec::new();
}
keys.sort_by(|a, b| comparator.compare(a, b));
keys.dedup_by(|a, b| comparator.compare(a, b).is_eq());
keys.pop();
keys
}
#[cfg(feature = "std")]
fn subcompaction_boundaries(
version: &Version,
dest_level: usize,
max_ranges: usize,
comparator: &crate::comparator::SharedComparator,
) -> Vec<UserKey> {
if max_ranges < 2 {
return Vec::new();
}
let Some(level) = version.level(dest_level) else {
return Vec::new();
};
let keys: Vec<UserKey> = level
.iter()
.flat_map(|run| run.iter())
.map(|t| t.metadata.key_range.max().clone())
.collect();
let keys = boundary_candidates(keys, comparator);
if keys.is_empty() {
return Vec::new();
}
let want = (max_ranges - 1).min(keys.len());
if want == keys.len() {
return keys;
}
let mut out = Vec::with_capacity(want);
for i in 1..=want {
let idx = ((i * keys.len()) / (want + 1)).min(keys.len() - 1);
if let Some(key) = keys.get(idx) {
out.push(key.clone());
}
}
out.dedup();
out
}
#[cfg(feature = "std")]
fn ranges_from_boundaries(
boundaries: &[UserKey],
) -> Vec<(core::ops::Bound<UserKey>, core::ops::Bound<UserKey>)> {
use core::ops::Bound::{Excluded, Included, Unbounded};
let mut ranges = Vec::with_capacity(boundaries.len() + 1);
let mut lo = Unbounded;
for b in boundaries {
ranges.push((lo.clone(), Included(b.clone())));
lo = Excluded(b.clone());
}
ranges.push((lo, Unbounded));
ranges
}
pub(super) fn cancelled_compaction() -> crate::Error {
crate::Error::from(crate::io::Error::new(
crate::io::ErrorKind::Interrupted,
"compaction cancelled by stop signal",
))
}
#[cfg(feature = "std")]
#[derive(Clone, Debug, PartialEq, Eq)]
struct TightSlice {
end: Option<UserKey>,
tables: u64,
blobs: u64,
}
#[cfg(feature = "std")]
#[derive(Clone, Copy, Debug)]
struct SliceCaps {
tables: u64,
blobs: u64,
total: u64,
}
#[cfg(feature = "std")]
struct SliceOutputs<'a> {
target_size: u64,
cut_keys: &'a [UserKey],
}
#[cfg(feature = "std")]
struct Relocated<'a> {
stale: &'a HashSet<BlobFileId>,
target_size: u64,
file_groups: u64,
}
#[cfg(feature = "std")]
const BLOB_META_BYTES: u64 = 1_024;
#[cfg(feature = "std")]
fn frame_bytes(key: &[u8], on_disk_size: u32) -> u64 {
crate::vlog::blob_file::writer::BLOB_HEADER_LEN as u64
+ key.len() as u64
+ u64::from(on_disk_size)
}
#[cfg(feature = "std")]
fn stale_frame_bytes(item: &InternalValue, stale: &HashSet<BlobFileId>) -> crate::Result<u64> {
use crate::blob_tree::field_row::{RowCell, decode_row};
use crate::blob_tree::handle::BlobIndirection;
use crate::coding::Decode;
let key = &item.key.user_key;
if item.key.value_type.is_indirection() {
let indirection = BlobIndirection::decode_from(&mut &item.value[..])?;
return Ok(if stale.contains(&indirection.vhandle.blob_file_id) {
frame_bytes(key, indirection.vhandle.on_disk_size)
} else {
0
});
}
if item.key.value_type.is_cell_row() {
let mut bytes = 0;
for field in decode_row(&item.value)? {
if let RowCell::Ref { indirection, .. } = field.cell
&& stale.contains(&indirection.vhandle.blob_file_id)
{
bytes += frame_bytes(key, indirection.vhandle.on_disk_size);
}
}
return Ok(bytes);
}
Ok(0)
}
#[cfg(feature = "std")]
fn tight_slices(
inputs: &[Table],
caps: SliceCaps,
outputs: &SliceOutputs<'_>,
relocated: Option<&Relocated<'_>>,
comparator: &crate::comparator::SharedComparator,
pace: &crate::table::util::Pacer,
) -> crate::Result<Vec<TightSlice>> {
struct Block<'a> {
end_key: UserKey,
tables: u64,
blobs: u64,
sources: Vec<(&'a Table, crate::table::BlockHandle, (u64, u64))>,
}
const ENTRY_FRAMING: u64 = 32;
let cmp = comparator.as_ref();
let mut blocks: Vec<Block> = Vec::new();
let mut longest_key = 0u64;
let mut fixed: Option<u64> = None;
for input in inputs {
let handles = input
.maintenance_index_walk()
.with_pace(Arc::clone(pace))
.collect::<crate::Result<Vec<_>>>()?;
let data_bytes: u64 = handles.iter().map(|h| u64::from(h.size())).sum();
if let Some(past_blocks) = input
.file_len()
.checked_sub(data_bytes)
.and_then(|past| past.checked_sub(input.data_scaled_section_bytes()))
{
fixed = Some(fixed.map_or(past_blocks, |least| least.min(past_blocks)));
}
for handle in handles {
let end_key = handle.end_key().clone();
if input
.restrict_lower_bound()
.is_some_and(|bound| cmp.compare(&end_key, bound) == core::cmp::Ordering::Less)
{
continue;
}
let tables = u64::try_from(
u128::from(handle.size()) * u128::from(input.file_len())
/ u128::from(data_bytes.max(1)),
)
.unwrap_or(u64::MAX);
let mut blobs = 0u64;
if let Some(relocated) = relocated
&& let Some(block) =
input.load_data_block_for_maintenance(handle.as_ref(), &**pace)?
{
block.try_for_each_entry(comparator.clone(), |item| {
longest_key = longest_key.max(item.key.user_key.len() as u64);
blobs += stale_frame_bytes(&item, relocated.stale)?;
Ok(())
})?;
}
blocks.push(Block {
end_key,
tables,
blobs,
sources: alloc::vec![(
input,
*handle.as_ref(),
(input.file_len(), data_bytes.max(1)),
)],
});
}
}
blocks.sort_by(|a, b| cmp.compare(&a.end_key, &b.end_key));
let mut grouped: Vec<Block> = Vec::with_capacity(blocks.len());
for block in blocks {
match grouped.last_mut() {
Some(group)
if cmp.compare(&group.end_key, &block.end_key) == core::cmp::Ordering::Equal =>
{
group.tables += block.tables;
group.blobs += block.blobs;
group.sources.extend(block.sources);
}
_ => grouped.push(block),
}
}
let mut blocks = grouped;
let fixed = fixed.unwrap_or(0);
let mut cut_keys = outputs.cut_keys.iter().peekable();
for block in &mut blocks {
while cut_keys
.next_if(|key| cmp.compare(key, &block.end_key) != core::cmp::Ordering::Greater)
.is_some()
{
block.tables += fixed;
}
}
let with_tables = |tables: u64| {
let tables = tables + tables / 16;
tables + fixed * (1 + tables / outputs.target_size.max(1))
};
let with_blobs = |blobs: u64| match relocated {
Some(relocated) if blobs > 0 => {
let files = blobs.div_ceil(relocated.target_size.max(1)) + relocated.file_groups;
blobs + files * (BLOB_META_BYTES + 2 * longest_key)
}
_ => blobs,
};
let past = |acc: (u64, u64), block: &Block| {
let tables = with_tables(acc.0 + block.tables);
let blobs = with_blobs(acc.1 + block.blobs);
tables > caps.tables || blobs > caps.blobs || tables + blobs > caps.total
};
let key_weight = |group: &Block| -> crate::Result<(u64, u64)> {
let mut weight = (0u64, 0u64);
for &(input, handle, (file_len, data_bytes)) in &group.sources {
let Some(block) = input.load_data_block_for_maintenance(&handle, &**pace)? else {
continue;
};
block.try_for_each_entry(comparator.clone(), |item| {
if cmp.compare(&item.key.user_key, &group.end_key) != core::cmp::Ordering::Equal {
return Ok(());
}
let raw = item.key.user_key.len() as u64 + item.value.len() as u64 + ENTRY_FRAMING;
weight.0 +=
u64::try_from(u128::from(raw) * u128::from(file_len) / u128::from(data_bytes))
.unwrap_or(u64::MAX);
if let Some(relocated) = relocated {
weight.1 += stale_frame_bytes(&item, relocated.stale)?;
}
Ok(())
})?;
}
Ok(weight)
};
let mut slices = Vec::new();
let mut acc = (0u64, 0u64);
let mut last: Option<&Block> = None;
for block in &blocks {
if let Some(previous) = last
&& past(acc, block)
{
slices.push(TightSlice {
end: Some(previous.end_key.clone()),
tables: with_tables(acc.0),
blobs: with_blobs(acc.1),
});
acc = key_weight(previous)?;
}
acc = (acc.0 + block.tables, acc.1 + block.blobs);
last = Some(block);
}
slices.push(TightSlice {
end: None,
tables: with_tables(acc.0),
blobs: with_blobs(acc.1),
});
Ok(slices)
}
#[cfg(feature = "std")]
fn slice_payload_for(views: &[Table], payload: &CompactionPayload) -> CompactionPayload {
CompactionPayload {
table_ids: views.iter().map(Table::id).collect(),
dest_level: payload.dest_level,
canonical_level: payload.canonical_level,
target_size: payload.target_size,
}
}
#[cfg(feature = "std")]
fn run_tight_space_compaction(
mut compaction_state: CompactionGuard<'_>,
version_history_lock: VersionsReadGuard<'_>,
opts: &Options,
payload: &CompactionPayload,
) -> crate::Result<CompactionResult> {
use core::ops::Bound;
let (dest_path, dest_fs) = opts.config.tables_folder_for_level(payload.dest_level);
if !dest_fs.capabilities(&dest_path).punch_hole {
log::info!("Tight-space compaction unavailable: backend lacks punch_hole");
return Ok(CompactionResult::nothing());
}
opts.deletion_pause.retry_pending_reclaims();
let latest = version_history_lock.latest_version();
let Some(inputs) = payload
.table_ids
.iter()
.map(|&id| latest.version.get_table(id).cloned())
.collect::<Option<Vec<_>>>()
else {
return Ok(CompactionResult::nothing());
};
let stale_files = match &opts.config.kv_separation_opts {
Some(blob_opts) => {
let picked: HashSet<TableId> = payload.table_ids.iter().copied().collect();
pick_blob_files_to_rewrite(&picked, &latest.version, blob_opts)?
}
None => Vec::new(),
};
if let Some(bf) = stale_files
.iter()
.find(|bf| !bf.0.fs.capabilities(&bf.0.path).punch_hole)
{
log::info!(
"Tight-space compaction unavailable: the backend of blob file {} \
lacks punch_hole, so its relocated prefix would stay allocated",
bf.0.path.display(),
);
return Ok(CompactionResult::nothing());
}
let mut stale_total_bytes = 0u64;
for bf in &stale_files {
stale_total_bytes += bf.physical_size()?;
}
let estimate = merge_estimate(opts, &latest.version, payload, &inputs, &stale_files, None);
let stale_ids: Vec<BlobFileId> = stale_files.iter().map(BlobFile::id).collect();
let mut codecs: Vec<crate::CompressionType> = Vec::new();
for bf in &stale_files {
if !codecs.contains(&bf.compression()) {
codecs.push(bf.compression());
}
}
let stale_codecs = codecs.len() as u64;
drop(stale_files);
let relocating = !stale_ids.is_empty();
if let Some(t) = inputs
.iter()
.find(|t| !t.fs.capabilities(&t.path).punch_hole)
{
log::info!(
"Tight-space compaction unavailable: the backend of input table {} lacks \
punch_hole, so its consumed prefix would stay allocated",
t.path.display(),
);
return Ok(CompactionResult::nothing());
}
let inputs_total: u64 = inputs.iter().map(Table::file_len).sum();
let dest_free = opts.capacity.available(&*dest_fs, &dest_path);
let dest_held = opts.capacity.held_on(&*dest_fs, &dest_path);
if dest_free < inputs_total {
let remote_total: u64 = inputs
.iter()
.filter(|t| separate_volumes(&*t.fs, &t.path, &*dest_fs, &dest_path))
.map(Table::file_len)
.sum();
if dest_free < remote_total {
log::info!(
"Tight-space compaction unavailable: {remote_total} bytes of input live on a \
different volume than the constrained destination and only {dest_free} bytes \
are free there, so the slices would grow it without relief",
);
return Ok(CompactionResult::nothing());
}
}
let quota_limit = opts.runtime_config.load().storage_limit_bytes;
let used_at_start = crate::storage_stats::compute_used_bytes(&latest.version)?;
let quota_headroom = match quota_limit {
Some(limit) => limit
.saturating_sub(used_at_start)
.saturating_sub(opts.capacity.quota_held(opts.tree_id)),
None => u64::MAX,
};
let mut own_tables: HashSet<TableId> = payload.table_ids.iter().copied().collect();
let mut own_blobs: HashSet<BlobFileId> = stale_ids.iter().copied().collect();
let others_at_start =
crate::storage_stats::compute_used_bytes_but(&latest.version, &own_tables, &own_blobs)?;
let cut_keys = super::flavour::output_cut_keys(&latest.version, opts, payload);
drop(latest);
drop(version_history_lock);
if inputs.is_empty() {
return Ok(CompactionResult::nothing());
}
let tables_in = inputs.len();
let comparator = opts.config.comparator.clone();
let blob_dir = opts.config.path.join(BLOBS_FOLDER);
let relocating_blobs = relocating && stale_total_bytes > 0;
let blob_free = if relocating_blobs {
opts.capacity.available(&*opts.config.fs, &blob_dir)
} else {
0
};
let blob_held = opts.capacity.held_on(&*opts.config.fs, &blob_dir);
let shared_volume =
relocating_blobs && !separate_volumes(&*dest_fs, &dest_path, &*opts.config.fs, &blob_dir);
let caps = if !relocating_blobs {
SliceCaps {
tables: dest_free.max(1),
blobs: u64::MAX,
total: quota_headroom.max(1),
}
} else if !shared_volume {
SliceCaps {
tables: dest_free.max(1),
blobs: blob_free.max(1),
total: quota_headroom.max(1),
}
} else {
SliceCaps {
tables: u64::MAX,
blobs: u64::MAX,
total: dest_free.min(blob_free).min(quota_headroom).max(1),
}
};
let stale_set: HashSet<BlobFileId> = stale_ids.iter().copied().collect();
let relocated = relocating_blobs.then(|| Relocated {
stale: &stale_set,
target_size: opts
.config
.kv_separation_opts
.as_ref()
.map_or(u64::MAX, |blob_opts| blob_opts.file_target_size),
file_groups: stale_codecs
* opts
.config
.kv_separation_opts
.as_ref()
.map_or(1, |blob_opts| u64::from(blob_opts.lifetime_groups.get())),
});
let outputs = SliceOutputs {
target_size: payload.target_size,
cut_keys: &cut_keys,
};
let slices = tight_slices(
&inputs,
caps,
&outputs,
relocated.as_ref(),
&comparator,
&input_pacer(opts),
)?;
let Some((tail, slices)) = slices.split_last() else {
return Ok(CompactionResult::nothing());
};
let past_caps = |slice: &TightSlice| {
slice.tables > caps.tables
|| slice.blobs > caps.blobs
|| slice.tables + slice.blobs > caps.total
};
if past_caps(slices.first().unwrap_or(tail)) {
return Ok(CompactionResult::nothing());
}
opts.work.plan(estimate);
let reserve_slice = |slice: &TightSlice,
own_tables: &HashSet<TableId>,
own_blobs: &HashSet<BlobFileId>|
-> crate::Result<bool> {
if past_caps(slice) {
return Ok(false);
}
let quota_headroom = match quota_limit {
Some(limit) => {
let others = crate::storage_stats::compute_used_bytes_but(
&opts.version_history.read().latest_version().version,
own_tables,
own_blobs,
)?;
limit
.saturating_sub(used_at_start)
.saturating_sub(others.saturating_sub(others_at_start))
}
None => u64::MAX,
};
let demands = [
super::capacity::Demand {
fs: &*dest_fs,
path: &dest_path,
bytes: slice.tables,
},
super::capacity::Demand {
fs: &*opts.config.fs,
path: &blob_dir,
bytes: slice.blobs,
},
];
#[cfg(all(test, feature = "std"))]
{
let squeeze = opts
.config
.tight_slice_squeeze
.swap(0, core::sync::atomic::Ordering::SeqCst);
if squeeze > 0 {
core::mem::forget(opts.capacity.hold(
TreeId::MAX,
&[super::capacity::Demand {
fs: &*dest_fs,
path: &dest_path,
bytes: squeeze,
}],
));
}
}
let (tables_room, blobs_room) = if shared_volume {
let both = slice.tables + slice.blobs;
(both, both)
} else {
(slice.tables, slice.blobs)
};
let [tables, blobs] = demands;
let within = [
(tables, dest_held + dest_free.saturating_sub(tables_room)),
(blobs, blob_held + blob_free.saturating_sub(blobs_room)),
];
opts.outputs.release_room();
let check = super::capacity::Check {
quota_headroom,
floor: super::capacity::Floor::MayConsume,
};
Ok(
match opts.capacity.reserve_within(opts.tree_id, &within, check) {
Some(room) => {
opts.outputs.reserve(room);
true
}
None => false,
},
)
};
let mut stopped = false;
let mut reclaim_deferred = false;
compaction_state
.hidden_set_mut()
.hide(payload.table_ids.iter().copied());
let dst_lvl: usize = payload.canonical_level.into();
let bottommost_gc = false;
let blobs_folder = opts.config.path.join(BLOBS_FOLDER);
let mut rts: Vec<crate::range_tombstone::RangeTombstone> = inputs
.iter()
.flat_map(|t| t.range_tombstones().iter().cloned())
.collect();
rts.sort();
rts.dedup();
let mut current_views = inputs;
let mut lower: Bound<UserKey> = Bound::Unbounded;
let mut resume_offsets: crate::HashMap<BlobFileId, u64> = crate::HashMap::default();
let rc = opts.runtime_config.load_full();
let result = (|| -> crate::Result<usize> {
let mut tables_out = 0usize;
for (slice, boundary) in slices
.iter()
.filter_map(|slice| slice.end.as_ref().map(|end| (slice, end)))
{
if reclaim_deferred || !reserve_slice(slice, &own_tables, &own_blobs)? {
stopped = true;
return Ok(tables_out);
}
let slice_payload = slice_payload_for(¤t_views, payload);
let version = opts.version_history.read().latest_version();
let current_stale: Vec<BlobFile> = if relocating {
stale_ids
.iter()
.filter_map(|id| version.version.blob_files.get(*id).cloned())
.collect()
} else {
Vec::new()
};
let reloc = if relocating {
Some(RelocationSetup {
stale_files: current_stale.clone(),
resume_offsets: resume_offsets.clone(),
})
} else {
None
};
let span = (lower.clone(), Bound::Excluded(boundary.clone()));
let filter_sizing = super::flavour::plan_filters(
&version.version,
opts,
&slice_payload,
&[],
Some(span.clone()),
&rc,
);
let produced = run_subcompaction(
opts,
&slice_payload,
&version.version,
Vec::new(),
&rts,
None,
span,
dst_lvl,
bottommost_gc,
false,
&blobs_folder,
reloc,
&rc,
filter_sizing.clone(),
)?;
drop(version);
let outputs: Vec<Table> = produced.created_tables().to_vec();
own_tables.extend(outputs.iter().map(Table::id));
own_blobs.extend(produced.created_blob_files().iter().map(BlobFile::id));
for table in &outputs {
table.bind_to_tree(&crate::table::TableSinks {
deletion_pause: &opts.deletion_pause,
heal_hints: &opts.heal_hints,
read_budget: opts.config.columnar_read_budget,
#[cfg(feature = "std")]
background_deleter: &opts.background_deleter,
track_filter_probes: opts.config.filter_advisor.is_some(),
});
}
let mut new_blobs: Vec<BlobFile> = produced.created_blob_files().to_vec();
let frag = produced.blob_frag_map().clone();
let gc_diff = if frag.is_empty() { None } else { Some(frag) };
let released_objects = produced.released_objects().to_vec();
if relocating {
for (id, fe) in produced.consumed_through() {
let slot = resume_offsets.entry(*id).or_insert(0);
*slot = (*slot).max(*fe);
}
}
let relocated_bytes = produced.relocated_bytes();
let carried = produced.carried();
let installed_work = produced.installed_work()?;
let mut installed_files = crate::tree::InstalledCharge::default();
installed_files.add(
&outputs,
produced.created_blob_files(),
crate::fs::WriteOrigin::CompactionTable,
crate::fs::WriteOrigin::CompactionBlob,
)?;
drop(produced);
#[cfg(feature = "page_ecc")]
let heal_locks: Vec<Arc<parking_lot::Mutex<()>>> = current_views
.iter()
.filter(|v| {
comparator.compare(v.metadata.key_range.max(), boundary)
!= core::cmp::Ordering::Less
})
.map(Table::heal_lock_arc)
.collect();
#[cfg(feature = "page_ecc")]
let _heal_guards: Vec<parking_lot::MutexGuard<'_, ()>> =
heal_locks.iter().map(|l| l.lock()).collect();
crate::table::probe_stats::inherit_into(&outputs, ¤t_views)?;
let mut prior_to_punch: Vec<(BlobFile, u64)> = Vec::new();
if relocating {
#[cfg(test)]
if opts
.config
.fail_tight_blob_reopen
.swap(false, core::sync::atomic::Ordering::SeqCst)
{
return Err(cancelled_compaction());
}
for sf in ¤t_stale {
if let Some(off) = resume_offsets.get(&sf.id()).copied() {
new_blobs.push(sf.reopen_restricted(off)?);
prior_to_punch.push((sf.clone(), off));
}
}
}
let mut restricted_pairs: Vec<(TableId, Table)> = Vec::new();
let mut removed_ids: Vec<TableId> = Vec::new();
let mut next_views: Vec<Table> = Vec::new();
for view in ¤t_views {
if comparator.compare(view.metadata.key_range.max(), boundary)
== core::cmp::Ordering::Less
{
removed_ids.push(view.id());
} else {
let bound = match view.restrict_lower_bound() {
Some(committed)
if comparator.compare(committed, boundary)
== core::cmp::Ordering::Greater =>
{
committed.clone()
}
_ => boundary.clone(),
};
let restricted = view.reopen_restricted(bound)?;
restricted_pairs.push((view.id(), restricted.clone()));
next_views.push(restricted);
}
}
for blob_file in &new_blobs {
blob_file.bind_to_tree(&crate::table::TableSinks {
deletion_pause: &opts.deletion_pause,
heal_hints: &opts.heal_hints,
read_budget: opts.config.columnar_read_budget,
#[cfg(feature = "std")]
background_deleter: &opts.background_deleter,
track_filter_probes: false,
});
}
#[cfg(test)]
opts.config.fire_before_output_install();
if let Some(sizing) = &filter_sizing {
sizing.release_replaced();
}
let mut unpunched: crate::HashSet<BlobFileId> = crate::HashSet::default();
let mut history = opts.version_history.write();
if !super::flavour::holds_every_input(
&history.latest_version().version,
current_views.iter().map(Table::id),
) {
log::warn!(
"Compaction inputs left the version before a slice's install, declining it"
);
return Ok(tables_out);
}
history.upgrade_version(
&opts.config.path,
|sv| {
unpunched.extend(
prior_to_punch
.iter()
.map(|(bf, _)| bf.id())
.filter(|id| sv.memtables_reference_blob_file(*id)),
);
let new_blobs: Vec<BlobFile> = new_blobs
.into_iter()
.filter(|bf| !unpunched.contains(&bf.id()))
.collect();
let mut copy = sv.clone();
let ctx = crate::version::TransformContext::new(comparator.as_ref());
copy.version = copy.version.with_tight_slice(
&restricted_pairs,
&removed_ids,
&outputs,
new_blobs,
gc_diff,
payload.dest_level as usize,
&ctx,
);
Ok(copy)
},
&opts.global_seqno,
&opts.visible_seqno,
&*opts.config.fs,
opts.runtime_config.load_full(),
opts.encryption.clone(),
crate::version::RetentionEffect::GcBelow(opts.gc_watermark),
)?;
if !released_objects.is_empty() {
let published = history.latest_version().version.id();
history.released().record(published, released_objects, []);
}
drop(history);
opts.outputs.installed();
opts.work.installed(installed_work);
installed_files.charge();
#[cfg(feature = "metrics")]
super::flavour::charge_installed(&opts.metrics, relocated_bytes, carried);
#[cfg(not(feature = "metrics"))]
let _ = (relocated_bytes, carried);
drop(filter_sizing);
let mut restricted: Vec<(&Table, &UserKey)> = Vec::new();
for view in ¤t_views {
if removed_ids.contains(&view.id()) {
view.mark_as_deleted();
} else {
let bound = restricted_pairs
.iter()
.find(|(id, _)| *id == view.id())
.and_then(|(_, restricted)| restricted.restrict_lower_bound())
.unwrap_or(boundary);
restricted.push((view, bound));
}
}
let writes: Vec<_> = restricted
.iter()
.map(|(view, bound)| view.restrict_sidecar_write(bound))
.collect();
let published = crate::restrict_bound::write_many(&writes, opts.config.sync_mode);
drop(writes);
for ((view, bound), result) in restricted.into_iter().zip(published) {
match result.and_then(|()| view.punch_offset_for(bound)) {
Ok(off) => view.mark_punch_on_drop(off),
Err(e) => log::error!(
"tight-space could not arm the prefix punch of table {} after \
committing its restriction: {e}; leaving the input unpunched, \
a later compaction rewrites it",
view.id(),
),
}
}
for (bf, off) in &prior_to_punch {
if !unpunched.contains(&bf.id()) {
bf.mark_punch_on_drop(*off);
}
}
tables_out += outputs.len();
let let_go_tables: Vec<_> = current_views.iter().map(Table::downgrade).collect();
let let_go_blobs: Vec<_> = prior_to_punch
.iter()
.filter(|(bf, _)| !unpunched.contains(&bf.id()))
.map(|(bf, _)| bf.downgrade())
.collect();
current_views = next_views;
lower = Bound::Included(boundary.clone());
drop(prior_to_punch);
drop(current_stale);
reclaim_deferred = let_go_tables.iter().any(|view| view.strong_count() > 0)
|| let_go_blobs.iter().any(|view| view.strong_count() > 0)
|| opts.deletion_pause.is_active();
#[cfg(test)]
if opts
.config
.fail_tight_after_first_slice
.swap(false, core::sync::atomic::Ordering::SeqCst)
{
return Err(cancelled_compaction());
}
if current_views.is_empty() {
return Ok(tables_out);
}
}
if reclaim_deferred || !reserve_slice(tail, &own_tables, &own_blobs)? {
stopped = true;
return Ok(tables_out);
}
let slice_payload = slice_payload_for(¤t_views, payload);
let version = opts.version_history.read().latest_version();
let tail_reloc = if relocating {
let current_stale: Vec<BlobFile> = stale_ids
.iter()
.filter_map(|id| version.version.blob_files.get(*id).cloned())
.collect();
Some(RelocationSetup {
stale_files: current_stale,
resume_offsets: resume_offsets.clone(),
})
} else {
None
};
let produced = run_subcompaction(
opts,
&slice_payload,
&version.version,
current_views.clone(),
&rts,
None,
(lower.clone(), Bound::Unbounded),
dst_lvl,
bottommost_gc,
false,
&blobs_folder,
tail_reloc,
&rc,
super::flavour::plan_filters(
&version.version,
opts,
&slice_payload,
&[],
Some((lower.clone(), Bound::Unbounded)),
&rc,
),
)?;
drop(version);
let tail_out = produced.created_tables().len();
let installed = super::flavour::install_merge(
&mut opts.version_history.write(),
opts,
&slice_payload,
vec![produced],
)?;
Ok(tables_out + installed.map_or(0, |_| tail_out))
})();
drop(rc);
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
let tables_out = result?;
if stopped && tables_out == 0 {
log::info!("Tight-space compaction stopped: its first slice no longer fits the room left");
return Ok(CompactionResult::nothing());
}
Ok(CompactionResult {
action: CompactionAction::Merged,
dest_level: Some(payload.dest_level),
tables_in,
tables_out,
work: super::WorkReport::default(),
})
}
#[cfg(feature = "std")]
struct RelocationSetup {
stale_files: Vec<BlobFile>,
resume_offsets: crate::HashMap<BlobFileId, u64>,
}
#[cfg(feature = "std")]
#[expect(
clippy::too_many_arguments,
reason = "a sub-compaction needs the full compaction context (opts, payload, \
version, inputs, range, level info, blob folder) threaded in by value/ref \
so it can run on its own thread; bundling into a struct would just move \
the argument list"
)]
fn run_subcompaction(
opts: &Options,
payload: &CompactionPayload,
version: &Version,
tables_for_deletion: Vec<Table>,
input_range_tombstones: &[crate::range_tombstone::RangeTombstone],
outside_spans: Option<&OutsideSpans>,
bounds: (core::ops::Bound<UserKey>, core::ops::Bound<UserKey>),
dst_lvl: usize,
is_last_level: bool,
apply_compaction_filter: bool,
blobs_folder: &std::path::Path,
relocation: Option<RelocationSetup>,
rc: &Arc<crate::runtime_config::RuntimeConfig>,
filter_sizing: Option<crate::filter_budget::FilterPlan>,
) -> crate::Result<super::flavour::ProducedOutput> {
use super::flavour::CompactionFlavour;
#[cfg(test)]
if opts
.config
.fail_one_subcompaction
.swap(false, std::sync::atomic::Ordering::SeqCst)
{
return Err(cancelled_compaction());
}
let mut ownership = super::ownership::OwnershipLedger::default();
if let Some(reloc) = &relocation {
ownership.relocating(reloc.stale_files.iter().map(BlobFile::id));
}
let ledger = core::cell::RefCell::new(ownership);
let ledger_error = core::cell::RefCell::new(None);
let mut ledger_hook = super::ownership::LedgerHook {
ledger: &ledger,
error: &ledger_error,
};
let completeness = input_completeness_for(version, &payload.table_ids, &opts.config);
let drain_below = match (&relocation, &bounds.1) {
(Some(_), core::ops::Bound::Excluded(upper)) => Some(upper.clone()),
_ => None,
};
let answered = answered_span(&bounds);
let range_upper = match &bounds.1 {
core::ops::Bound::Included(upper) | core::ops::Bound::Excluded(upper) => {
Some(upper.clone())
}
core::ops::Bound::Unbounded => None,
};
#[cfg(feature = "columnar")]
let carry = if relocation.is_some() {
None
} else {
super::carry::plan(
&opts.config,
rc,
payload.canonical_level.into(),
&version
.iter_tables()
.filter(|t| payload.table_ids.contains(&t.id()))
.cloned()
.collect::<Vec<_>>(),
)
};
let pace = input_pacer(opts);
let Some(mut merge_iter) = create_bounded_compaction_stream(
version,
&payload.table_ids,
bounds,
opts.gc_watermark,
opts.config.merge_operator.clone(),
opts.config.comparator.clone(),
Some(&pace),
#[cfg(feature = "columnar")]
carry.as_ref(),
)?
else {
return Err(crate::Error::from(crate::io::Error::other(
"sub-compaction input tables disappeared mid-flight",
)));
};
let rt_plan = range_tombstone_plan(version, is_last_level, &opts.config);
let holds_everything = holds_every_surviving_version(
version,
&payload.table_ids,
is_last_level,
opts.config.comparator.as_ref(),
);
merge_iter = merge_iter
.evict_tombstones(holds_everything)
.zero_seqnos(false);
if let Some((tombstones, rt_use)) = &rt_plan {
merge_iter = merge_iter.with_range_tombstones(
tombstones.clone(),
opts.config.comparator.clone(),
*rt_use,
);
}
if let Some(predicate) = &completeness {
merge_iter = merge_iter.with_input_completeness(predicate);
}
let version_tombstones = zeroing_tombstones_from(rt_plan.as_ref());
let filter_ctx = Context { is_last_level };
let mut compaction_filter = if apply_compaction_filter {
opts.config
.compaction_filter_factory
.as_ref()
.map(|f| f.make_filter(&filter_ctx))
} else {
None
};
let transform_marker = Some(Arc::new(portable_atomic::AtomicU64::new(0)));
if let Some(marker) = &transform_marker {
merge_iter = merge_iter.with_transform_marker(Arc::clone(marker));
}
if opts.config.kv_separation_opts.is_some() {
merge_iter = merge_iter.with_drop_callback(&mut ledger_hook);
}
let gc_balance = merge_iter.gc_balance();
let mut filter_blob_writer = None;
let relocating = relocation.is_some() && opts.config.kv_separation_opts.is_some();
let filter_marker = Arc::new(portable_atomic::AtomicU64::new(0));
let merge_iter = merge_iter.with_filter(StreamFilterAdapter::new(
compaction_filter.as_deref_mut(),
opts,
version,
FilterBlobOutput {
folder: blobs_folder,
writer: (!relocating).then_some(&mut filter_blob_writer),
runtime: rc,
},
&filter_ctx,
TransformCounters {
transform: transform_marker.clone(),
filter: Arc::clone(&filter_marker),
},
));
let merge_iter = super::seqno_zeroer::BottommostSeqnoZeroer::new(
merge_iter,
holds_everything,
version_tombstones,
opts.gc_watermark,
opts.config.comparator.clone(),
Arc::clone(&gc_balance),
);
let mut table_writer = super::flavour::prepare_table_writer(
version,
opts,
payload,
filter_sizing,
false,
transform_marker,
false,
rc,
)?;
if let Some(upper) = range_upper {
table_writer.limit_cut_alignment(upper);
}
let mut compactor: Box<dyn CompactionFlavour> = match relocation {
Some(reloc) if opts.config.kv_separation_opts.is_some() => {
#[expect(clippy::expect_used, reason = "guarded by is_some() above")]
let blob_opts = opts
.config
.kv_separation_opts
.as_ref()
.expect("kv_separation_opts present");
let scanner = BlobFileMergeScanner::new(
reloc
.stale_files
.iter()
.map(|bf| {
let from = reloc.resume_offsets.get(&bf.id()).copied().unwrap_or(0);
open_blob_scanner_at_frontier(bf, from, opts)
})
.collect::<crate::Result<Vec<_>>>()?,
opts.config.comparator.clone(),
);
let writer = BlobFileWriter::new_for(
opts.blob_file_id_generator.clone(),
blobs_folder,
opts.tree_id,
opts.config.descriptor_table.clone(),
opts.config.fs.clone(),
crate::fs::WriteOrigin::CompactionBlob,
)?
.use_output_ledger(opts.outputs.clone())
.use_target_size(blob_opts.file_target_size)
.use_passthrough_compression(rc.blob_compression)
.use_sync_mode(opts.config.sync_mode)
.use_writeback_bytes(opts.config.writeback_bytes)
.use_lifetime_groups(blob_opts.lifetime_groups)
.use_clock(opts.config.clock.clone())
.use_key_domains(
opts.config.key_domains.clone(),
opts.config.comparator.clone(),
);
let writer = with_fresh_encoding(writer, opts, rc);
let inner = StandardCompaction::new(table_writer, tables_for_deletion);
Box::new(
RelocatingCompaction::new(
inner,
scanner.peekable(),
writer,
reloc.stale_files,
opts.rate_limiter.clone(),
opts.stop_signal.clone(),
opts.config.comparator.clone(),
opts.tree_id,
)
.with_drain_below(drain_below)
.with_fresh_values(FreshValues {
options: blob_opts.clone(),
compression: rc.blob_compression,
}),
)
}
_ => Box::new(StandardCompaction::new(table_writer, tables_for_deletion)),
};
if !input_range_tombstones.is_empty() {
let built;
let outside_spans = if let Some(spans) = outside_spans {
spans
} else {
built = OutsideSpans::new(version, &payload.table_ids, opts.config.comparator.as_ref());
&built
};
let output_rts = range_tombstones_after_gc(
input_range_tombstones,
outside_spans,
opts.gc_watermark,
is_last_level,
&opts.config.comparator,
);
if !output_rts.is_empty() {
let outside = tombstones_outside(
&output_rts,
outside_spans,
answered,
&opts.config.comparator,
);
compactor.write_range_tombstones(&output_rts, outside);
}
}
#[cfg(feature = "columnar")]
let mut matcher = compactor.carrying().and(carry.as_ref()).map(|carry| {
super::carry::CarryMatcher::new(Arc::clone(&carry.queue), opts.config.comparator.clone())
});
#[cfg(feature = "columnar")]
let read_pace: Option<&dyn crate::table::util::ReadPacer> = Some(&*pace);
let mut emit = |row: InternalValue| -> crate::Result<()> {
#[cfg(feature = "columnar")]
if let Some(matcher) = matcher.as_mut()
&& let Some(flavour) = compactor.carrying()
{
return matcher.write(
row,
&mut FlavourSink {
flavour,
pace: read_pace,
},
);
}
compactor.write(row)
};
for (idx, item) in merge_iter.enumerate() {
let item = item?;
let io_bytes = item.key.user_key.len() as u64 + item.value.len() as u64;
if opts
.rate_limiter
.request_abortable(io_bytes, || opts.stop_signal.is_stopped())
{
return Err(cancelled_compaction());
}
ledger.borrow_mut().admit(item, &mut emit)?;
if let Some(e) = ledger_error.borrow_mut().take() {
return Err(e);
}
if idx % 1_000_000 == 0 && opts.stop_signal.is_stopped() {
return Err(cancelled_compaction());
}
}
if let Some(filter) = compaction_filter {
filter.finish();
}
let extra_blob_files = filter_blob_writer
.map(BlobFileWriter::finish)
.transpose()?
.unwrap_or_default();
let (blob_frag_map, released_objects) = ledger.into_inner().finish(&mut emit)?;
if let Some(e) = ledger_error.into_inner() {
return Err(e);
}
#[cfg(feature = "columnar")]
let carried = match (matcher.take(), compactor.carrying()) {
(Some(matcher), Some(flavour)) => matcher.finish(&mut FlavourSink {
flavour,
pace: read_pace,
})?,
_ => super::carry::Carried::default(),
};
let mut produced = compactor.produce(opts, dst_lvl, blob_frag_map, extra_blob_files)?;
produced.set_released_objects(released_objects);
#[cfg(feature = "columnar")]
produced.set_carried(carried);
if filter_marker.load(core::sync::atomic::Ordering::Relaxed) > 0 {
produced.mark_filter_transformed();
}
if gc_balance.load(core::sync::atomic::Ordering::Relaxed) > 0 {
produced.mark_collected_below_watermark();
}
Ok(produced)
}
#[expect(
clippy::significant_drop_tightening,
reason = "the hidden-set check and the install must run under one version lock"
)]
fn move_tables(
compaction_state: &CompactionGuard<'_>,
opts: &Options,
payload: &CompactionPayload,
) -> crate::Result<CompactionResult> {
#[cfg(test)]
opts.config.fire_before_output_install();
let mut version_history_lock = opts.version_history.write();
if compaction_state
.hidden_set()
.should_decline_compaction(payload.table_ids.iter().copied())
{
log::warn!(
"Compaction task created by {:?} contained hidden tables, declining to run it - please report this at https://github.com/fjall-rs/lsm-tree/issues/new?template=bug_report.md",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
}
if !super::flavour::holds_every_input(
&version_history_lock.latest_version().version,
payload.table_ids.iter().copied(),
) {
log::warn!("Tables to move left the version before the install, declining the move");
return Ok(CompactionResult::nothing());
}
let table_count = payload.table_ids.len();
let table_ids = payload.table_ids.iter().copied().collect::<Vec<_>>();
let estimate = {
let version = &version_history_lock.latest_version().version;
super::work::estimate_move(table_ids.iter().filter_map(|&id| version.get_table(id)))
};
opts.work.plan(estimate);
version_history_lock.upgrade_version(
&opts.config.path,
|current| {
let mut copy = current.clone();
let ctx = crate::version::TransformContext::new(opts.config.comparator.as_ref());
copy.version = copy
.version
.with_moved(&table_ids, payload.dest_level as usize, &ctx);
Ok(copy)
},
&opts.global_seqno,
&opts.visible_seqno,
&*opts.config.fs,
opts.runtime_config.load_full(),
opts.encryption.clone(),
crate::version::RetentionEffect::Keep,
)?;
opts.work.moved(estimate.expected.moved);
Ok(CompactionResult {
action: CompactionAction::Moved,
dest_level: Some(payload.dest_level),
tables_in: table_count,
tables_out: table_count,
work: super::WorkReport::default(),
})
}
pub fn pick_blob_files_to_rewrite(
picked_tables: &HashSet<TableId>,
current_version: &Version,
blob_opts: &crate::KvSeparationOptions,
) -> crate::Result<Vec<BlobFile>> {
use crate::Table;
let linked_blob_files = picked_tables
.iter()
.map(|&id| {
current_version.get_table(id).unwrap_or_else(|| {
panic!("Table {id} should exist");
})
})
.map(Table::list_blob_file_references)
.collect::<Result<Vec<_>, _>>()?;
let mut linked_blob_files = linked_blob_files
.into_iter()
.flatten()
.flatten()
.map(|blob_file_ref| {
current_version
.blob_files
.get(blob_file_ref.blob_file_id)
.unwrap_or_else(|| {
panic!("Blob file {} should exist", blob_file_ref.blob_file_id);
})
})
.filter(|blob_file| {
blob_file.is_stale(current_version.gc_stats(), blob_opts.staleness_threshold)
})
.filter(|blob_file| {
!blob_file.is_dead(current_version.gc_stats())
})
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
linked_blob_files.sort_by_key(|a| a.id());
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "precision loss and truncation are acceptable for cutoff calculation"
)]
let cutoff_point = {
let len = linked_blob_files.len() as f32;
(len * blob_opts.age_cutoff) as usize
};
linked_blob_files.drain(cutoff_point..);
for table in current_version.iter_tables() {
if picked_tables.contains(&table.id()) {
continue;
}
let other_refs = table
.list_blob_file_references()?
.unwrap_or_default()
.into_iter()
.filter(|x| linked_blob_files.iter().any(|bf| bf.id() == x.blob_file_id))
.collect::<Vec<_>>();
for additional_ref in other_refs {
linked_blob_files.retain(|x| x.id() != additional_ref.blob_file_id);
}
}
Ok(linked_blob_files.into_iter().cloned().collect::<Vec<_>>())
}
struct BlobRewrite {
files: Vec<BlobFile>,
#[cfg_attr(
not(feature = "std"),
expect(dead_code, reason = "read only by the std merge-on-read fast path")
)]
for_locality: bool,
}
fn pick_blob_files_for_merge(
opts: &Options,
version: &Version,
payload: &CompactionPayload,
tables: &[Table],
) -> crate::Result<BlobRewrite> {
let Some(blob_opts) = &opts.config.kv_separation_opts else {
return Ok(BlobRewrite {
files: Vec::new(),
for_locality: false,
});
};
let mut files = pick_blob_files_to_rewrite(&payload.table_ids, version, blob_opts)?;
let mut for_locality = false;
if let Some(locality) = blob_opts.locality_relocation {
let extra = pick_blob_files_for_locality(opts, version, payload, tables, locality, &files)?;
for_locality = !extra.is_empty();
files.extend(extra);
}
Ok(BlobRewrite {
files,
for_locality,
})
}
#[cfg_attr(
not(zstd_any),
expect(
clippy::missing_const_for_fn,
reason = "the zstd build reads the dictionaries"
)
)]
fn with_fresh_encoding(
writer: BlobFileWriter,
opts: &Options,
rc: &crate::runtime_config::RuntimeConfig,
) -> BlobFileWriter {
#[cfg(zstd_any)]
{
let dicts = opts.config.current_zstd_dictionaries();
let dictionary = dicts.for_compression(rc.blob_compression).ok().flatten();
writer
.use_zstd_dictionary(dictionary)
.use_zstd_two_pass_seed(rc.zstd_two_pass_seed)
.use_zstd_dictionaries(dicts)
}
#[cfg(not(zstd_any))]
{
let _ = (opts, rc);
writer
}
}
fn open_blob_scanner_at_frontier(
bf: &BlobFile,
from: u64,
opts: &Options,
) -> crate::Result<BlobFileScanner> {
let off = from.max(bf.live_data_start());
BlobFileScanner::open_paced(
&bf.0.path,
&*bf.0.fs,
bf.id(),
(off != 0).then_some(off),
Some(input_pacer(opts)),
)
}
fn moves_into_locality_relocation(
version: &Version,
opts: &Options,
payload: &CompactionPayload,
) -> crate::Result<bool> {
let Some(blob_opts) = &opts.config.kv_separation_opts else {
return Ok(false);
};
let Some(locality) = blob_opts.locality_relocation else {
return Ok(false);
};
let Some(tables) = payload
.table_ids
.iter()
.map(|&id| version.get_table(id).cloned())
.collect::<Option<Vec<_>>>()
else {
return Ok(false);
};
let stale = pick_blob_files_to_rewrite(&payload.table_ids, version, blob_opts)?;
Ok(
!pick_blob_files_for_locality(opts, version, payload, &tables, locality, &stale)?
.is_empty(),
)
}
fn pick_blob_files_for_locality(
opts: &Options,
version: &Version,
payload: &CompactionPayload,
tables: &[Table],
locality: crate::config::BlobLocalityRelocation,
stale: &[BlobFile],
) -> crate::Result<Vec<BlobFile>> {
let max_depth = locality.max_depth.get();
let depths = crate::storage_stats::blob_file_depths(tables)?;
log::trace!("Blob file depths of the merge: {depths:?}");
let mut candidates: Vec<(BlobFile, u64, u64)> = Vec::new();
for (id, depth) in depths {
if depth <= max_depth || stale.iter().any(|bf| bf.id() == id) {
continue;
}
let Some(blob_file) = version.blob_files.get(id) else {
continue;
};
if blob_file.is_dead(version.gc_stats()) {
continue;
}
candidates.push((blob_file.clone(), depth, blob_file.physical_size()?));
}
if candidates.is_empty() {
return Ok(Vec::new());
}
for table in outside_tables(version, &payload.table_ids) {
let links = table.blob_links()?;
candidates.retain(|(bf, _, _)| !links.iter().any(|link| link.blob_file_id == bf.id()));
}
let mut table_bytes = 0u64;
let mut stale_written = 0u64;
for table in tables {
let len = table.file_len();
table_bytes += len.checked_sub(table.punch_offset()?).unwrap_or(len);
for link in table.blob_links()? {
if stale.iter().any(|bf| bf.id() == link.blob_file_id) {
stale_written += link.on_disk_bytes;
}
}
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "the budget is a fraction of a byte count; rounding is fine"
)]
let budget = ((table_bytes + stale_written) as f64 * f64::from(locality.budget)) as u64;
candidates.sort_by(|(a, a_depth, a_size), (b, b_depth, b_size)| {
let a_score = u128::from(*a_depth) * u128::from((*b_size).max(1));
let b_score = u128::from(*b_depth) * u128::from((*a_size).max(1));
b_score.cmp(&a_score).then_with(|| a.id().cmp(&b.id()))
});
let mut codecs: Vec<crate::CompressionType> = Vec::new();
let mut group_of = |bf: &BlobFile| {
let codec = bf.compression();
codecs.iter().position(|c| *c == codec).unwrap_or_else(|| {
codecs.push(codec);
codecs.len() - 1
})
};
let mut fixed: Vec<(crate::vlog::BlobFileId, usize)> =
stale.iter().map(|bf| (bf.id(), group_of(bf))).collect();
fixed.sort_unstable_by_key(|&(id, _)| id);
let ranked: Vec<(crate::vlog::BlobFileId, usize, u64)> = candidates
.iter()
.map(|(bf, _, size)| (bf.id(), group_of(bf), 2 * size))
.collect();
let (chosen, spent) =
crate::storage_stats::pick_overlapping_blob_files(tables, &fixed, &ranked, budget)?;
let picked: Vec<(BlobFile, u64)> = candidates
.into_iter()
.zip(chosen)
.filter_map(|((bf, _, size), chosen)| chosen.then_some((bf, size)))
.collect();
log::debug!(
"Locality relocation: {} of the candidates fit {spent} of {budget} budget bytes",
picked.len(),
);
if picked.is_empty() {
return Ok(Vec::new());
}
let picked_bytes: u64 = picked.iter().map(|(_, size)| size).sum();
let picked: Vec<BlobFile> = picked.into_iter().map(|(bf, _)| bf).collect();
let rc = opts.runtime_config.load_full();
let quota_headroom = match rc.storage_limit_bytes {
Some(limit) => limit.saturating_sub(crate::storage_stats::compute_used_bytes(version)?),
None => u64::MAX,
};
let blob_dir = opts.config.path.join(BLOBS_FOLDER);
let extra = super::capacity::Demand {
fs: &*opts.config.fs,
path: &blob_dir,
bytes: picked_bytes,
};
let check = super::capacity::Check {
quota_headroom,
floor: super::capacity::Floor::Keep,
};
if !opts
.outputs
.try_reserve_more(&opts.capacity, opts.tree_id, &extra, check)
{
log::debug!("Skipping locality relocation: the output would eat into the reserve");
return Ok(Vec::new());
}
log::debug!(
"Relocating blob files for locality: {:?}",
picked.iter().map(BlobFile::id).collect::<Vec<_>>(),
);
Ok(picked)
}
fn hidden_guard<T>(
payload: &CompactionPayload,
opts: &Options,
f: impl FnOnce() -> crate::Result<T>,
) -> crate::Result<T> {
f().inspect_err(|e| {
log::error!("Compaction failed: {e:?}");
let mut compaction_state = opts.compaction_state.lock();
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
})
}
#[cfg(feature = "std")]
fn plan_merge_on_read(
opts: &Options,
payload: &CompactionPayload,
tables: &[Table],
input_range_tombstones: &[crate::range_tombstone::RangeTombstone],
pace: &crate::table::util::Pacer,
) -> crate::Result<Option<(Table, crate::table::delete_bitmap::DeleteBitmap)>> {
let dst_lvl: usize = payload.canonical_level.into();
let strategy = opts.runtime_config.load_full().delete_strategy.get(dst_lvl);
if !strategy.writes_bitmap() {
return Ok(None);
}
let [source] = tables else {
return Ok(None);
};
if !source.metadata.columnar
|| source.encryption.is_some()
|| source.metadata.ecc_params.is_some()
|| source.metadata.ecc_unrecognized
|| source.zone_map.is_empty()
|| !source.delete_bitmap().is_empty()
|| source.restrict_lower_bound().is_some()
{
return Ok(None);
}
let keys = source
.scan_paced(Some(pace))?
.map(|entry| {
let entry = entry?;
Ok((entry.key.user_key, entry.key.seqno))
})
.collect::<crate::Result<Vec<_>>>()?;
if let (Some(domains), Some((first, _)), Some((last, _))) =
(opts.config.key_domains.as_ref(), keys.first(), keys.last())
{
use core::cmp::Ordering::{Greater, Less};
let cmp = opts.config.comparator.as_ref();
let end = domains.domain_end(first);
let rows_leave = end
.as_ref()
.is_some_and(|end| cmp.compare(last, end) != Less);
if rows_leave
|| input_range_tombstones.iter().any(|rt| {
domains.domain_end(&rt.start) != end
|| end
.as_ref()
.is_some_and(|end| cmp.compare(&rt.end, end) == Greater)
})
{
return Ok(None);
}
}
let bitmap = super::delete_materialize::build_position_bitmap(
keys.iter()
.map(|(user_key, seqno)| (user_key.as_ref(), *seqno)),
input_range_tombstones,
opts.gc_watermark,
&opts.config.comparator,
)?;
if bitmap.is_empty() {
return Ok(None);
}
if let crate::config::DeleteStrategy::Adaptive {
purge_threshold_percent,
} = strategy
{
let item_count = source.metadata.item_count.max(1);
let deleted_percent = bitmap.len() * 100 / item_count;
if deleted_percent >= u64::from(purge_threshold_percent) {
return Ok(None);
}
}
Ok(Some((source.clone(), bitmap)))
}
#[cfg(feature = "std")]
fn run_merge_on_read_relocation(
compaction_state: &mut CompactionGuard<'_>,
opts: &Options,
payload: &CompactionPayload,
source: &Table,
bitmap: &crate::table::delete_bitmap::DeleteBitmap,
pace: &crate::table::util::Pacer,
) -> crate::Result<CompactionResult> {
let dst_lvl: usize = payload.canonical_level.into();
let (folder, level_fs) = opts.config.tables_folder_for_level(payload.dest_level);
let crate::table::relocate::Relocated {
id: new_id,
path: new_path,
entry_durable,
checksum,
copied,
} = source.relocate_columnar_with_deletes(
|| {
opts.table_files.claim_as(
&folder,
&level_fs,
Some(source.id()),
true,
crate::fs::WriteOrigin::CompactionTable,
)
},
&*level_fs,
bitmap,
opts.config.sync_mode,
Some(pace),
)?;
opts.outputs
.record_table(new_id, new_path.clone(), Arc::clone(&level_fs));
opts.outputs.landed(&*level_fs, &new_path)?;
let relocated = {
let mut params = crate::table::RecoverParams::new(
new_path,
checksum,
new_id,
Arc::clone(&level_fs),
opts.config.comparator.clone(),
opts.config.cache.clone(),
);
params.recency = Some(source.l0_recency());
params.tree_id = opts.tree_id;
params
.descriptor_table
.clone_from(&opts.config.descriptor_table);
params.pin_filter = opts.config.filter_block_pinning_policy.get(dst_lvl);
params.pin_index = opts.config.index_block_pinning_policy.get(dst_lvl);
params.encryption.clone_from(&opts.config.encryption);
params
.prefix_extractor
.clone_from(&opts.config.prefix_extractor);
#[cfg(zstd_any)]
{
params.zstd_dictionaries = opts.config.current_zstd_dictionaries();
}
#[cfg(feature = "metrics")]
{
params.metrics = opts.metrics.clone();
}
let table = Table::recover(params)?;
if entry_durable {
table.mark_entry_durable();
}
table
};
compaction_state
.hidden_set_mut()
.hide(payload.table_ids.iter().copied());
let produced =
super::flavour::ProducedOutput::for_relocation(relocated, source.clone(), copied);
let result = super::flavour::install_merge(
&mut opts.version_history.write(),
opts,
payload,
vec![produced],
);
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
let Some(tables_out) = result? else {
return Ok(CompactionResult::nothing());
};
Ok(CompactionResult {
action: CompactionAction::Merged,
dest_level: Some(payload.dest_level),
tables_in: 1,
tables_out,
work: super::WorkReport::default(),
})
}
#[expect(clippy::too_many_lines)]
fn merge_tables(
mut compaction_state: CompactionGuard<'_>,
version_history_lock: VersionsReadGuard<'_>,
opts: &Options,
payload: &CompactionPayload,
) -> crate::Result<CompactionResult> {
if opts.stop_signal.is_stopped() {
log::debug!("Stopping before compaction because of stop signal");
return Ok(CompactionResult::nothing());
}
if compaction_state
.hidden_set()
.should_decline_compaction(payload.table_ids.iter().copied())
{
log::warn!(
"Compaction task created by {:?} contained hidden tables, declining to run it - please report this at https://github.com/fjall-rs/lsm-tree/issues/new?template=bug_report.md",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
}
let current_super_version = Arc::new(version_history_lock.latest_version());
let Some(tables) = payload
.table_ids
.iter()
.map(|&id| current_super_version.version.get_table(id).cloned())
.collect::<Option<Vec<_>>>()
else {
log::warn!(
"Compaction task created by {:?} contained tables not referenced in the level manifest",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
};
let tables_in = payload.table_ids.len();
let mut input_range_tombstones: Vec<crate::range_tombstone::RangeTombstone> = tables
.iter()
.flat_map(|t| t.range_tombstones().iter().cloned())
.collect();
input_range_tombstones.sort();
input_range_tombstones.dedup();
let blob_rewrite =
pick_blob_files_for_merge(opts, ¤t_super_version.version, payload, &tables)?;
let is_last_level = payload.dest_level == opts.config.level_count - 1;
let rt_plan = range_tombstone_plan(¤t_super_version.version, is_last_level, &opts.config);
opts.work.plan(merge_estimate(
opts,
¤t_super_version.version,
payload,
&tables,
&blob_rewrite.files,
rt_plan.as_ref(),
));
let pace = input_pacer(opts);
#[cfg(feature = "std")]
if !blob_rewrite.for_locality
&& let Some((source, bitmap)) =
plan_merge_on_read(opts, payload, &tables, &input_range_tombstones, &pace)?
{
drop(current_super_version);
drop(version_history_lock);
return run_merge_on_read_relocation(
&mut compaction_state,
opts,
payload,
&source,
&bitmap,
&pace,
);
}
let blob_files_to_rewrite = blob_rewrite.files;
#[cfg(feature = "std")]
{
let threads = opts.config.compaction_threads;
let dst_lvl: usize = payload.canonical_level.into();
let relocating = !blob_files_to_rewrite.is_empty();
let total_input_bytes: u64 = tables.iter().map(Table::file_size).sum();
let boundaries = if threads > 1
&& !relocating
&& total_input_bytes >= opts.config.subcompaction_min_bytes
{
subcompaction_boundaries(
¤t_super_version.version,
payload.dest_level as usize,
threads,
&opts.config.comparator,
)
} else {
Vec::new()
};
if !boundaries.is_empty() {
let blobs_folder = opts.config.path.join(BLOBS_FOLDER);
let ranges = ranges_from_boundaries(&boundaries);
drop(version_history_lock);
compaction_state
.hidden_set_mut()
.hide(payload.table_ids.iter().copied());
drop(compaction_state);
let num_ranges = ranges.len();
let only_first_owns_inputs =
|idx: usize| if idx == 0 { tables.clone() } else { Vec::new() };
let rc = opts.runtime_config.load_full();
let filter_sizing = super::flavour::plan_filters(
¤t_super_version.version,
opts,
payload,
&boundaries,
None,
&rc,
);
let outside_spans = (!input_range_tombstones.is_empty()).then(|| {
Arc::new(OutsideSpans::new(
¤t_super_version.version,
&payload.table_ids,
opts.config.comparator.as_ref(),
))
});
let outputs: Vec<crate::Result<super::flavour::ProducedOutput>> =
if let Some(spawner) = opts.config.compaction_pool.clone() {
let opts = Arc::new(opts.clone());
let payload = Arc::new(payload.clone());
let version = Arc::clone(¤t_super_version);
let rts = Arc::new(input_range_tombstones.clone());
let blobs = Arc::new(blobs_folder);
let (tx, rx) = std::sync::mpsc::channel();
for (idx, range) in ranges.iter().cloned().enumerate() {
let tx = tx.clone();
let opts = Arc::clone(&opts);
let payload = Arc::clone(&payload);
let version = Arc::clone(&version);
let rts = Arc::clone(&rts);
let outside_spans = outside_spans.clone();
let blobs = Arc::clone(&blobs);
let rc = Arc::clone(&rc);
let filter_sizing = filter_sizing.clone();
let tables_for_deletion = only_first_owns_inputs(idx);
spawner.spawn(Box::new(move || {
let out = run_subcompaction(
&opts,
&payload,
&version.version,
tables_for_deletion,
&rts,
outside_spans.as_deref(),
range,
dst_lvl,
is_last_level,
true,
&blobs,
None,
&rc,
filter_sizing,
);
let _ = tx.send((idx, out));
}));
}
drop(tx);
let mut slots: Vec<Option<crate::Result<super::flavour::ProducedOutput>>> =
(0..num_ranges).map(|_| None).collect();
for (idx, out) in rx {
if let Some(slot) = slots.get_mut(idx) {
*slot = Some(out);
}
}
slots
.into_iter()
.map(|slot| {
slot.unwrap_or_else(|| {
Err(crate::Error::from(crate::io::Error::other(
"sub-compaction worker did not report a result",
)))
})
})
.collect()
} else {
ranges
.iter()
.cloned()
.enumerate()
.map(|(idx, range)| {
run_subcompaction(
opts,
payload,
¤t_super_version.version,
only_first_owns_inputs(idx),
&input_range_tombstones,
outside_spans.as_deref(),
range,
dst_lvl,
is_last_level,
true,
&blobs_folder,
None,
&rc,
filter_sizing.clone(),
)
})
.collect()
};
let outputs = match outputs.into_iter().collect::<crate::Result<Vec<_>>>() {
Ok(outputs) => outputs,
Err(err) => {
log::error!("Sub-compaction failed: {err:?}");
opts.compaction_state
.lock()
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
return Err(err);
}
};
#[cfg(test)]
opts.config.fire_before_output_install();
let mut compaction_state = opts.compaction_state.lock();
let mut version_history_lock = opts.version_history.write();
let tables_out =
super::flavour::install_merge(&mut version_history_lock, opts, payload, outputs)
.inspect_err(|e| {
log::error!("Sub-compaction install failed: {e:?}");
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
})?;
drop(rc);
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
drop(version_history_lock);
drop(compaction_state);
let Some(tables_out) = tables_out else {
return Ok(CompactionResult::nothing());
};
log::trace!("Parallel compaction done in {num_ranges} sub-ranges");
return Ok(CompactionResult {
action: CompactionAction::Merged,
dest_level: Some(payload.dest_level),
tables_in,
tables_out,
work: super::WorkReport::default(),
});
}
}
let ledger = core::cell::RefCell::new(super::ownership::OwnershipLedger::default());
let ledger_error = core::cell::RefCell::new(None);
let mut ledger_hook = super::ownership::LedgerHook {
ledger: &ledger,
error: &ledger_error,
};
let completeness = input_completeness_for(
¤t_super_version.version,
&payload.table_ids,
&opts.config,
);
let rc = opts.runtime_config.load_full();
#[cfg(feature = "columnar")]
let carry = if blob_files_to_rewrite.is_empty() {
super::carry::plan(&opts.config, &rc, payload.canonical_level.into(), &tables)
} else {
None
};
let Some(mut merge_iter) = create_compaction_stream(
¤t_super_version.version,
&payload.table_ids.iter().copied().collect::<Vec<_>>(),
opts.gc_watermark,
opts.config.merge_operator.clone(),
opts.config.comparator.clone(),
Some(&pace),
#[cfg(feature = "columnar")]
carry.as_ref(),
)?
else {
log::warn!(
"Compaction task tried to compact tables that do not exist, declining to run it"
);
return Ok(CompactionResult::nothing());
};
let dst_lvl = payload.canonical_level.into();
let holds_everything = holds_every_surviving_version(
¤t_super_version.version,
&payload.table_ids,
is_last_level,
opts.config.comparator.as_ref(),
);
merge_iter = merge_iter
.evict_tombstones(holds_everything)
.zero_seqnos(false);
if let Some((tombstones, rt_use)) = &rt_plan {
merge_iter = merge_iter.with_range_tombstones(
tombstones.clone(),
opts.config.comparator.clone(),
*rt_use,
);
}
if let Some(predicate) = &completeness {
merge_iter = merge_iter.with_input_completeness(predicate);
}
let zeroing_tombstones = zeroing_tombstones_from(rt_plan.as_ref());
let blobs_folder = opts.config.path.join(BLOBS_FOLDER);
let filter_ctx = Context { is_last_level };
let mut compaction_filter = opts.config.compaction_filter_factory.as_ref().map(|f| {
log::trace!("Installing custom compaction filter {:?}", f.name());
f.make_filter(&filter_ctx)
});
let transform_marker = Some(Arc::new(portable_atomic::AtomicU64::new(0)));
if let Some(marker) = &transform_marker {
merge_iter = merge_iter.with_transform_marker(Arc::clone(marker));
}
let gc_balance = merge_iter.gc_balance();
let mut filter_blob_writer = None;
let relocating = opts.config.kv_separation_opts.is_some() && !blob_files_to_rewrite.is_empty();
let filter_marker = Arc::new(portable_atomic::AtomicU64::new(0));
let mut merge_iter = merge_iter.with_filter(StreamFilterAdapter::new(
compaction_filter.as_deref_mut(),
opts,
¤t_super_version.version,
FilterBlobOutput {
folder: &blobs_folder,
writer: (!relocating).then_some(&mut filter_blob_writer),
runtime: &rc,
},
&filter_ctx,
TransformCounters {
transform: transform_marker.clone(),
filter: Arc::clone(&filter_marker),
},
));
let table_writer = super::flavour::prepare_table_writer(
¤t_super_version.version,
opts,
payload,
super::flavour::plan_filters(
¤t_super_version.version,
opts,
payload,
&[],
None,
&rc,
),
true,
transform_marker,
true,
&rc,
)?;
let start = Instant::now();
let mut compactor = match &opts.config.kv_separation_opts {
Some(blob_opts) => {
merge_iter = merge_iter.with_drop_callback(&mut ledger_hook);
if blob_files_to_rewrite.is_empty() {
log::debug!("No blob relocation needed");
Box::new(StandardCompaction::new(table_writer, tables))
as Box<dyn super::flavour::CompactionFlavour>
} else {
log::debug!(
"Relocate blob files: {:?}",
blob_files_to_rewrite
.iter()
.map(BlobFile::id)
.collect::<Vec<_>>(),
);
ledger
.borrow_mut()
.relocating(blob_files_to_rewrite.iter().map(BlobFile::id));
let scanner = BlobFileMergeScanner::new(
blob_files_to_rewrite
.iter()
.map(|bf| open_blob_scanner_at_frontier(bf, 0, opts))
.collect::<crate::Result<Vec<_>>>()?,
opts.config.comparator.clone(),
);
let writer = BlobFileWriter::new_for(
opts.blob_file_id_generator.clone(),
&blobs_folder,
opts.tree_id,
opts.config.descriptor_table.clone(),
opts.config.fs.clone(),
crate::fs::WriteOrigin::CompactionBlob,
)?
.use_output_ledger(opts.outputs.clone())
.use_target_size(blob_opts.file_target_size)
.use_passthrough_compression(rc.blob_compression)
.use_sync_mode(opts.config.sync_mode)
.use_writeback_bytes(opts.config.writeback_bytes)
.use_lifetime_groups(blob_opts.lifetime_groups)
.use_clock(opts.config.clock.clone())
.use_key_domains(
opts.config.key_domains.clone(),
opts.config.comparator.clone(),
);
let writer = with_fresh_encoding(writer, opts, &rc);
let inner = StandardCompaction::new(table_writer, tables);
Box::new(
RelocatingCompaction::new(
inner,
scanner.peekable(),
writer,
blob_files_to_rewrite,
opts.rate_limiter.clone(),
opts.stop_signal.clone(),
opts.config.comparator.clone(),
opts.tree_id,
)
.with_fresh_values(FreshValues {
options: blob_opts.clone(),
compression: rc.blob_compression,
}),
)
}
}
None => Box::new(StandardCompaction::new(table_writer, tables)),
};
log::trace!("Blob file GC preparation done in {:?}", start.elapsed());
drop(version_history_lock);
{
compaction_state
.hidden_set_mut()
.hide(payload.table_ids.iter().copied());
}
drop(compaction_state);
if !input_range_tombstones.is_empty() {
log::debug!(
"Propagating {} range tombstones to compaction output",
input_range_tombstones.len(),
);
let outside = tombstones_outside(
&input_range_tombstones,
&OutsideSpans::new(
¤t_super_version.version,
&payload.table_ids,
opts.config.comparator.as_ref(),
),
(None, None),
&opts.config.comparator,
);
compactor.write_range_tombstones(&input_range_tombstones, outside);
}
#[cfg(feature = "columnar")]
let mut matcher = compactor.carrying().and(carry.as_ref()).map(|carry| {
super::carry::CarryMatcher::new(Arc::clone(&carry.queue), opts.config.comparator.clone())
});
#[cfg(feature = "columnar")]
let read_pace: Option<&dyn crate::table::util::ReadPacer> = Some(&*pace);
let mut emit = |row: InternalValue| -> crate::Result<()> {
#[cfg(feature = "columnar")]
if let Some(matcher) = matcher.as_mut()
&& let Some(flavour) = compactor.carrying()
{
return matcher.write(
row,
&mut FlavourSink {
flavour,
pace: read_pace,
},
);
}
compactor.write(row)
};
hidden_guard(payload, opts, || {
let merge_iter = super::seqno_zeroer::BottommostSeqnoZeroer::new(
merge_iter,
holds_everything,
zeroing_tombstones,
opts.gc_watermark,
opts.config.comparator.clone(),
Arc::clone(&gc_balance),
);
for (idx, item) in merge_iter.enumerate() {
let item = item?;
let io_bytes = item.key.user_key.len() as u64 + item.value.len() as u64;
if opts
.rate_limiter
.request_abortable(io_bytes, || opts.stop_signal.is_stopped())
{
log::debug!("Stopping amidst compaction because of stop signal (I/O throttle)");
return Err(cancelled_compaction());
}
ledger.borrow_mut().admit(item, &mut emit)?;
if let Some(e) = ledger_error.borrow_mut().take() {
return Err(e);
}
#[cfg(all(test, feature = "std"))]
if opts
.config
.stop_serial_merge_after_first_item
.swap(false, core::sync::atomic::Ordering::SeqCst)
{
opts.stop_signal.send();
}
if idx % 1_000_000 == 0 && opts.stop_signal.is_stopped() {
log::debug!("Stopping amidst compaction because of stop signal");
return Err(cancelled_compaction());
}
}
Ok(())
})?;
let (blob_frag_map, released_objects) = hidden_guard(payload, opts, || {
let settled = ledger.into_inner().finish(&mut emit)?;
if let Some(e) = ledger_error.into_inner() {
return Err(e);
}
Ok(settled)
})?;
#[cfg(feature = "columnar")]
let carried = match (matcher.take(), compactor.carrying()) {
(Some(matcher), Some(flavour)) => hidden_guard(payload, opts, || {
matcher.finish(&mut FlavourSink {
flavour,
pace: read_pace,
})
})?,
_ => super::carry::Carried::default(),
};
#[cfg(feature = "columnar")]
log::debug!(
"Carried {} row groups ({} rows, {} bytes) into the output whole",
carried.groups,
carried.rows,
carried.bytes,
);
if let Some(filter) = compaction_filter {
filter.finish();
}
#[cfg(test)]
opts.config.fire_before_output_install();
let mut compaction_state = opts.compaction_state.lock();
log::trace!("Acquiring super version write lock");
let mut version_history_lock = opts.version_history.write();
log::trace!("Acquired super version write lock");
log::trace!("Blob fragmentation diff: {blob_frag_map:#?}");
let extra_blob_files = filter_blob_writer
.map(BlobFileWriter::finish)
.transpose()
.inspect_err(|e| {
log::error!("Compaction failed while finishing filter blob writer: {e:?}");
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
})?
.unwrap_or_default();
let mut produce_output = compactor
.produce(opts, dst_lvl, blob_frag_map, extra_blob_files)
.inspect_err(|e| {
log::error!("Compaction failed: {e:?}");
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
})?;
produce_output.set_released_objects(released_objects);
#[cfg(feature = "columnar")]
produce_output.set_carried(carried);
if filter_marker.load(core::sync::atomic::Ordering::Relaxed) > 0 {
produce_output.mark_filter_transformed();
}
if gc_balance.load(core::sync::atomic::Ordering::Relaxed) > 0 {
produce_output.mark_collected_below_watermark();
}
let tables_out = super::flavour::install_merge(
&mut version_history_lock,
opts,
payload,
vec![produce_output],
)
.inspect_err(|e| {
log::error!("Compaction failed: {e:?}");
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
})?;
drop(rc);
compaction_state
.hidden_set_mut()
.show(payload.table_ids.iter().copied());
drop(version_history_lock);
drop(compaction_state);
let Some(tables_out) = tables_out else {
return Ok(CompactionResult::nothing());
};
log::trace!("Compaction successful");
Ok(CompactionResult {
action: CompactionAction::Merged,
dest_level: Some(payload.dest_level),
tables_in,
tables_out,
work: super::WorkReport::default(),
})
}
#[cfg(feature = "columnar")]
struct FlavourSink<'a> {
flavour: &'a mut StandardCompaction,
pace: Option<&'a dyn crate::table::util::ReadPacer>,
}
#[cfg(feature = "columnar")]
impl super::carry::CarrySink for FlavourSink<'_> {
fn write(&mut self, row: InternalValue) -> crate::Result<()> {
super::flavour::CompactionFlavour::write(self.flavour, row)
}
fn carry(
&mut self,
candidate: &crate::table::group_carry::CarryCandidate,
) -> crate::Result<bool> {
self.flavour.carry(candidate, self.pace)
}
fn carry_pages(
&mut self,
candidate: &crate::table::group_carry::CarryCandidate,
emitted: &[InternalValue],
) -> crate::Result<Option<u64>> {
self.flavour.carry_pages(candidate, emitted, self.pace)
}
fn transforms_seen(&self) -> u64 {
self.flavour.transforms_seen()
}
fn settle_transforms(&mut self, seen: u64) {
self.flavour.settle_transforms(seen);
}
}
fn drop_tables(
compaction_state: &CompactionGuard<'_>,
opts: &Options,
ids_to_drop: &[TableId],
effect: crate::version::RetentionEffect,
) -> crate::Result<CompactionResult> {
if ids_to_drop.is_empty() {
return Ok(CompactionResult::nothing());
}
let mut version_history_lock = opts.version_history.write();
if compaction_state
.hidden_set()
.should_decline_compaction(ids_to_drop.iter().copied())
{
log::warn!(
"Compaction task created by {:?} contained hidden tables, declining to run it - please report this at https://github.com/fjall-rs/lsm-tree/issues/new?template=bug_report.md",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
}
let Some(tables) = ids_to_drop
.iter()
.map(|&id| {
version_history_lock
.latest_version()
.version
.get_table(id)
.cloned()
})
.collect::<Option<Vec<_>>>()
else {
log::warn!(
"Compaction task created by {:?} contained tables not referenced in the level manifest",
opts.strategy.get_name(),
);
return Ok(CompactionResult::nothing());
};
log::debug!("Dropping tables: {ids_to_drop:?}");
let mut dropped_blob_files = vec![];
let mut released_tables = Vec::new();
if version_history_lock.released().holds_reads() {
for table in &tables {
let owned = table.owned_blob_objects()?;
if !owned.is_empty() {
released_tables.push(crate::blob_tree::released::OwnedObjects::from(owned));
}
}
}
version_history_lock.upgrade_version(
&opts.config.path,
|current| {
let mut copy = current.clone();
let ctx = crate::version::TransformContext::new(opts.config.comparator.as_ref());
copy.version = copy.version.with_dropped(
ids_to_drop,
&mut dropped_blob_files,
&|id| current.memtables_reference_blob_file(id),
&ctx,
)?;
Ok(copy)
},
&opts.global_seqno,
&opts.visible_seqno,
&*opts.config.fs,
opts.runtime_config.load_full(),
opts.encryption.clone(),
effect,
)?;
if !released_tables.is_empty() {
let published = version_history_lock.latest_version().version.id();
version_history_lock
.released()
.record(published, [], released_tables);
}
drop(version_history_lock);
for table in tables {
table.mark_as_deleted();
}
for blob_file in dropped_blob_files {
blob_file.mark_as_deleted();
}
let tables_dropped = ids_to_drop.len();
log::trace!("Dropped {tables_dropped} tables");
Ok(CompactionResult {
action: CompactionAction::Dropped,
dest_level: None,
tables_in: tables_dropped,
tables_out: 0,
work: super::WorkReport::default(),
})
}
#[cfg(test)]
mod tests;
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test code")]
mod alignment_tests;
#[cfg(test)]
#[expect(clippy::expect_used, reason = "test code")]
mod move_tests;