Skip to main content

lsm_tree/compaction/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5//! Contains compaction strategies
6
7pub(crate) mod capacity;
8pub(crate) mod carry;
9pub(crate) mod covered;
10pub(crate) mod fifo;
11pub(crate) mod leveled;
12// pub(crate) mod maintenance;
13#[cfg(feature = "std")]
14pub(crate) mod delete_materialize;
15pub mod filter;
16mod flavour;
17pub(crate) mod heal;
18pub(crate) mod major;
19pub(crate) mod movedown;
20pub(crate) mod output_ledger;
21pub(crate) mod ownership;
22pub mod planned;
23pub(crate) mod pulldown;
24pub(crate) mod seqno_zeroer;
25pub(crate) mod state;
26pub(crate) mod stream;
27pub(crate) mod tiered;
28pub(crate) mod work;
29pub(crate) mod worker;
30
31pub use fifo::Strategy as Fifo;
32pub use filter::{CompactionFilter, Factory, ItemAccessor, Verdict};
33pub use heal::Strategy as EccHeal;
34pub use leveled::Strategy as Leveled;
35pub use leveled::{Granularity, Layout, LevelPolicy, Movement, Trigger};
36pub use planned::Strategy as Planned;
37pub use tiered::Strategy as SizeTiered;
38pub use work::{Work, WorkEstimate, WorkReport};
39
40pub use {
41    fifo::NAME as FIFO_COMPACTION_NAME, leveled::NAME as LEVELED_COMPACTION_NAME,
42    tiered::NAME as TIERED_COMPACTION_NAME,
43};
44
45/// Alias for `Leveled`
46pub type Levelled = Leveled;
47
48#[doc(hidden)]
49pub use movedown::Strategy as MoveDown;
50
51#[doc(hidden)]
52pub use pulldown::Strategy as PullDown;
53
54use crate::{
55    HashSet, KvPair, TableId, compaction::state::CompactionState, config::Config, version::Version,
56};
57#[cfg(not(feature = "std"))]
58use alloc::vec::Vec;
59
60/// The action taken during a compaction run.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum CompactionAction {
63    /// Strategy chose to do nothing.
64    Nothing,
65
66    /// Tables were merged (rewritten) into a destination level.
67    Merged,
68
69    /// Tables were moved to a deeper level without rewriting.
70    Moved,
71
72    /// Tables were dropped without compaction.
73    Dropped,
74}
75
76/// Result of a compaction operation, describing what happened.
77///
78/// Returned by [`crate::AbstractTree::compact`] to give callers
79/// observability into which compaction path was taken.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CompactionResult {
82    /// The action that was taken.
83    pub action: CompactionAction,
84
85    /// The destination level, if applicable.
86    pub dest_level: Option<u8>,
87
88    /// Number of input tables consumed.
89    pub tables_in: usize,
90
91    /// Number of output tables produced.
92    pub tables_out: usize,
93
94    /// The work the compaction was estimated to cost before it ran, and the
95    /// work it did, by kind of work.
96    pub work: WorkReport,
97}
98
99impl CompactionResult {
100    /// Creates a result for the "do nothing" case.
101    #[must_use]
102    pub fn nothing() -> Self {
103        Self {
104            action: CompactionAction::Nothing,
105            dest_level: None,
106            tables_in: 0,
107            tables_out: 0,
108            work: WorkReport::default(),
109        }
110    }
111}
112
113/// Input for compactor
114///
115/// The compaction strategy chooses which tables to compact and how.
116/// That information is given to the compactor.
117#[derive(Debug, Clone, Eq, PartialEq)]
118pub struct Input {
119    /// Tables to compact
120    pub table_ids: HashSet<TableId>,
121
122    /// Level to put the created tables into
123    pub dest_level: u8,
124
125    /// The logical level the tables are part of
126    pub canonical_level: u8,
127
128    /// Table target size
129    ///
130    /// If a table merge reaches the size threshold, a new table is started.
131    /// This results in a sorted "run" of tables.
132    pub target_size: u64,
133}
134
135/// Describes what to do (compact or not)
136#[derive(Debug, Eq, PartialEq)]
137pub enum Choice {
138    /// Just do nothing.
139    DoNothing,
140
141    /// Moves tables into another level without rewriting.
142    Move(Input),
143
144    /// Compacts some tables into a new level.
145    Merge(Input),
146
147    /// Delete tables without doing compaction.
148    ///
149    /// This may be used by a compaction strategy that wants to delete old data
150    /// without having to compact it away, like [`fifo::Strategy`].
151    Drop(HashSet<TableId>),
152}
153
154/// Trait for a compaction strategy
155///
156/// The strategy receives the levels of the LSM-tree as argument
157/// and emits a choice on what to do.
158#[expect(clippy::module_name_repetitions)]
159pub trait CompactionStrategy: Send + Sync {
160    /// Gets the compaction strategy name.
161    fn get_name(&self) -> &'static str;
162
163    #[doc(hidden)]
164    fn get_config(&self) -> Vec<KvPair> {
165        vec![]
166    }
167
168    /// Decides on what to do based on the current state of the LSM-tree's levels
169    ///
170    /// This is the purely *structural* decision (level shape, run counts, size
171    /// targets); it deliberately does not see the live runtime config. The
172    /// orchestrator layers the runtime-config-driven housekeeping fallback on
173    /// top (the density-based columnar rewrite).
174    fn choose(&self, version: &Version, config: &Config, state: &CompactionState) -> Choice;
175
176    /// Estimated bytes pending compaction: on-disk data currently sitting above
177    /// its level's target size that must eventually be rewritten downward (a
178    /// `RocksDB` `estimate-pending-compaction-bytes` analog). A scheduler /
179    /// tiering consumer reads it as a compaction-debt signal; `0` means the tree
180    /// is at or below its target shape.
181    ///
182    /// The default is `0` for strategies without a size-target notion of debt
183    /// (FIFO, major one-shot); the leveled strategy overrides it with
184    /// the per-level overflow sum.
185    fn pending_compaction_bytes(&self, _version: &Version) -> u64 {
186        0
187    }
188
189    /// The level a merge into `dest_level` writes outputs that are next merged
190    /// into, whose table boundaries they are cut on (see
191    /// [`Config::compaction_output_alignment`]); `None` cuts them at the target
192    /// size alone.
193    ///
194    /// The default is the first level below the destination that holds tables:
195    /// outputs move down through empty levels untouched and are next merged
196    /// where tables are, as a leveled intra-L0 merge's outputs are merged into
197    /// the base level. A strategy whose outputs are next merged elsewhere, such
198    /// as back into the level they were written to, overrides it.
199    fn next_merge_level(&self, version: &Version, dest_level: u8) -> Option<usize> {
200        (usize::from(dest_level) + 1..version.level_count())
201            .find(|&index| version.level(index).is_some_and(|level| !level.is_empty()))
202    }
203}
204
205/// Runs a strategy's structural decision and, only when it would otherwise idle,
206/// layers the runtime-config-driven density rewrite on top.
207///
208/// This keeps [`CompactionStrategy::choose`] purely structural (no runtime-config
209/// dependency) while applying the housekeeping fallback uniformly to every
210/// strategy at the orchestration layer, where the live `runtime_config` already
211/// lives. The fallback never preempts real work: it only fires when `choose`
212/// returned [`Choice::DoNothing`], and [`pick_density_rewrite`] self-gates to a
213/// no-op unless a level runs an [`Adaptive`](crate::config::DeleteStrategy::Adaptive)
214/// strategy and holds a dense-enough segment.
215pub(crate) fn choose_with_density_rewrite(
216    strategy: &dyn CompactionStrategy,
217    version: &Version,
218    config: &Config,
219    runtime_config: &crate::runtime_config::RuntimeConfig,
220    state: &CompactionState,
221) -> Choice {
222    let structural = strategy.choose(version, config, state);
223    if matches!(structural, Choice::DoNothing)
224        && let Some(input) = pick_density_rewrite(version, runtime_config, state)
225    {
226        return Choice::Merge(input);
227    }
228    structural
229}
230
231/// Picks a columnar segment whose positional delete-bitmap density has grown
232/// past its level's adaptive purge threshold, for a single-input materializing
233/// rewrite: the masked scan drops the dead rows and the fresh segment carries no
234/// bitmap, reclaiming the merge-on-read mask cost paid on every scan.
235///
236/// Returns the densest eligible candidate, or `None` when no level runs an
237/// [`Adaptive`](crate::config::DeleteStrategy::Adaptive) strategy (a fixed
238/// `CopyOnWrite` never accumulates a bitmap, a fixed `MergeOnRead` never purges)
239/// or no live segment is dense enough. Segments already enrolled in another
240/// compaction (`hidden_set`) are skipped. This self-gating is why the orchestrator
241/// can apply it uniformly after any strategy.
242///
243/// The live `runtime_config` (not the boot snapshot) supplies the per-level
244/// threshold, so an operator's `update_runtime_config` takes effect. The rewrite
245/// targets the candidate's own on-disk size, so the single survivor run stays one
246/// segment.
247pub(crate) fn pick_density_rewrite(
248    version: &Version,
249    runtime_config: &crate::runtime_config::RuntimeConfig,
250    state: &CompactionState,
251) -> Option<Input> {
252    // (density, id, level, file_size) of the densest eligible candidate.
253    let mut best: Option<(u8, TableId, usize, u64)> = None;
254    for (level_idx, level) in version.iter_levels().enumerate() {
255        let crate::config::DeleteStrategy::Adaptive {
256            purge_threshold_percent,
257        } = runtime_config.delete_strategy.get(level_idx)
258        else {
259            continue;
260        };
261        for table in level.iter().flat_map(|run| run.iter()) {
262            if state.hidden_set().is_hidden(table.id()) {
263                continue;
264            }
265            let Some(density) = table.delete_density() else {
266                continue;
267            };
268            if density < purge_threshold_percent {
269                continue;
270            }
271            let take = match best {
272                None => true,
273                Some((best_density, _, _, _)) => density > best_density,
274            };
275            if take {
276                best = Some((density, table.id(), level_idx, table.file_size()));
277            }
278        }
279    }
280
281    let (_, table_id, level_idx, file_size) = best?;
282    let mut table_ids = HashSet::default();
283    table_ids.insert(table_id);
284    #[expect(
285        clippy::cast_possible_truncation,
286        reason = "level index is bounded by level count (<= 7, technically 255)"
287    )]
288    let level = level_idx as u8;
289    Some(Input {
290        table_ids,
291        // In-place single-input rewrite: dest == source level. The masked scan
292        // materializes the survivors; plan_merge_on_read declines a
293        // bitmap-carrying source, so this runs the copy-on-write merge path.
294        dest_level: level,
295        canonical_level: level,
296        // Target the source's own size so the survivor run stays a single segment.
297        target_size: file_size,
298    })
299}