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}