Skip to main content

lsm_tree/compaction/tiered/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5use super::{Choice, CompactionStrategy, Input as CompactionInput};
6use crate::{
7    KvPair, TableId, compaction::state::CompactionState, config::Config, table::Table,
8    version::Version,
9};
10#[cfg(not(feature = "std"))]
11use alloc::vec::Vec;
12
13#[cfg(test)]
14mod tests;
15
16#[doc(hidden)]
17pub const NAME: &str = "SizeTieredCompaction";
18
19/// Size-tiered compaction strategy (STCS), also known as Universal compaction.
20///
21/// All sorted runs live in L0. When enough similarly-sized runs accumulate,
22/// they are merged into a single larger run (still in L0). This minimizes
23/// write amplification at the cost of higher read and space amplification.
24///
25/// Best for write-heavy workloads: posting list merges, counters, time-series
26/// append-only data.
27///
28/// # Algorithm
29///
30/// Only runs that are contiguous in L0 order (newest to oldest), and that no
31/// run ahead of them overlaps, are merged together, so the merged run is older
32/// than every run in front of it that shares a key with it and newer than
33/// every run behind it.
34///
35/// 1. **Space amplification check:** if `total_size / largest_run_size - 1`
36///    exceeds [`max_space_amplification_percent`](Strategy::with_max_space_amplification_percent),
37///    all runs are merged (full compaction). While a compaction holds a run
38///    between two others, this waits for it to land.
39/// 2. **Size-ratio merge:** walking L0 from the newest run, the first stretch
40///    of consecutive runs where each neighbouring pair satisfies
41///    `larger / smaller <= 1.0 + size_ratio`, that no run ahead of it
42///    overlaps, and that is at least
43///    [`min_merge_width`](Strategy::with_min_merge_width) long is merged (its
44///    newest [`max_merge_width`](Strategy::with_max_merge_width) runs). A
45///    stretch a run ahead overlaps is cut short there, and the runs behind
46///    are tried as stretches of their own.
47///
48/// # Trade-offs vs Leveled
49///
50/// | Metric | STCS | Leveled |
51/// |--------|------|---------|
52/// | Write amplification | ~O(N/T) | ~O(T×L) |
53/// | Read amplification | Higher (more runs) | Lower (1 run per level) |
54/// | Space amplification | Up to 2× temporary | ~1.1× |
55#[derive(Clone)]
56pub struct Strategy {
57    /// Maximum allowed size ratio between adjacent sorted runs (by size) for
58    /// them to be considered "similar" and eligible for merging together.
59    ///
60    /// For two adjacent runs sorted by size, if `larger / smaller <= 1.0 + size_ratio`,
61    /// they are considered similar.
62    ///
63    /// Default = 1.0 (adjacent run can be up to 2× the previous).
64    size_ratio: f64,
65
66    /// Minimum number of similarly-sized sorted runs required before
67    /// triggering a merge.
68    ///
69    /// Default = 4.
70    min_merge_width: usize,
71
72    /// Maximum number of sorted runs to merge at once.
73    ///
74    /// Default = `usize::MAX` (unlimited).
75    max_merge_width: usize,
76
77    /// When space amplification exceeds this percentage, a full compaction
78    /// of all runs is triggered.
79    ///
80    /// Space amplification is computed as `(total_size / largest_run_size - 1) × 100`.
81    ///
82    /// Default = 200 (i.e. 200%, meaning total data can be up to 3× the largest run).
83    max_space_amplification_percent: u64,
84
85    /// Target table size on disk (possibly compressed) for output tables.
86    ///
87    /// Default = 64 MiB.
88    target_size: u64,
89}
90
91impl Default for Strategy {
92    fn default() -> Self {
93        Self {
94            size_ratio: 1.0,
95            min_merge_width: 4,
96            max_merge_width: usize::MAX,
97            max_space_amplification_percent: 200,
98            target_size: 64 * 1_024 * 1_024,
99        }
100    }
101}
102
103impl Strategy {
104    /// Sets the size ratio threshold for considering runs "similar".
105    ///
106    /// Two adjacent runs (sorted by size) are similar if
107    /// `larger / smaller <= 1.0 + size_ratio`.
108    ///
109    /// Same as `compaction_options_universal.size_ratio` in `RocksDB`.
110    ///
111    /// Default = 1.0
112    #[must_use]
113    pub fn with_size_ratio(mut self, ratio: f64) -> Self {
114        // Clamp invalid values: NaN, negative, and infinite are replaced
115        // with the default (1.0). Zero is allowed (exact-size-match only).
116        self.size_ratio = if ratio.is_finite() && ratio >= 0.0 {
117            ratio
118        } else {
119            1.0
120        };
121        self
122    }
123
124    /// Sets the minimum number of runs to merge at once.
125    ///
126    /// Same as `compaction_options_universal.min_merge_width` in `RocksDB`.
127    ///
128    /// Default = 4
129    #[must_use]
130    pub fn with_min_merge_width(mut self, width: usize) -> Self {
131        self.min_merge_width = width.max(2);
132        self
133    }
134
135    /// Sets the maximum number of runs to merge at once.
136    ///
137    /// Same as `compaction_options_universal.max_merge_width` in `RocksDB`.
138    ///
139    /// Default = `usize::MAX`
140    #[must_use]
141    pub fn with_max_merge_width(mut self, width: usize) -> Self {
142        self.max_merge_width = width.max(2);
143        self
144    }
145
146    /// Sets the space amplification threshold (in percent) that triggers
147    /// a full compaction of all runs.
148    ///
149    /// Same as `compaction_options_universal.max_size_amplification_percent` in `RocksDB`.
150    ///
151    /// Default = 200
152    #[must_use]
153    pub fn with_max_space_amplification_percent(mut self, percent: u64) -> Self {
154        self.max_space_amplification_percent = percent;
155        self
156    }
157
158    /// Sets the target table size on disk (possibly compressed).
159    ///
160    /// Default = 64 MiB
161    #[must_use]
162    pub fn with_table_target_size(mut self, bytes: u64) -> Self {
163        self.target_size = bytes;
164        self
165    }
166}
167
168/// Per-run metadata for compaction decisions.
169struct RunInfo {
170    /// Total on-disk size of all tables in this run.
171    size: u64,
172
173    /// Table IDs belonging to this run.
174    table_ids: Vec<TableId>,
175}
176
177/// Collects L0 runs in L0 order (newest first), `None` for a run with a
178/// table in the hidden set (being compacted).
179fn collect_runs(version: &Version, state: &CompactionState) -> Vec<Option<RunInfo>> {
180    version
181        .l0()
182        .iter()
183        .map(|run| {
184            if run
185                .iter()
186                .any(|table| state.hidden_set().is_hidden(table.id()))
187            {
188                return None;
189            }
190
191            let size = run.iter().map(Table::file_size).sum::<u64>();
192            let table_ids = run.iter().map(Table::id).collect();
193
194            Some(RunInfo { size, table_ids })
195        })
196        .collect()
197}
198
199/// Whether two runs neighbouring in L0 are close enough in size to merge.
200fn similar(a: &RunInfo, b: &RunInfo, size_ratio: f64) -> bool {
201    let (smaller, larger) = if a.size <= b.size {
202        (a.size, b.size)
203    } else {
204        (b.size, a.size)
205    };
206    if smaller == 0 {
207        // A zero-size run is similar to anything.
208        return true;
209    }
210    #[expect(
211        clippy::cast_precision_loss,
212        reason = "precision loss is acceptable for ratio comparison"
213    )]
214    let ratio = larger as f64 / smaller as f64;
215    ratio <= 1.0 + size_ratio
216}
217
218/// For each L0 run, the frontmost run ahead of it (L0 is newest first, so the
219/// lowest index) that holds a table overlapping one of its tables, if any.
220/// Computed once per choice: every candidate stretch is then checked against
221/// it without looking at a table again.
222fn frontmost_overlap_ahead(
223    version: &Version,
224    cmp: &dyn crate::comparator::UserComparator,
225) -> Vec<Option<usize>> {
226    let l0 = version.l0();
227    l0.iter()
228        .enumerate()
229        .map(|(at, run)| {
230            l0.iter().take(at).position(|ahead| {
231                ahead.iter().any(|table| {
232                    !run.get_overlapping_cmp(&table.metadata.key_range, cmp)
233                        .is_empty()
234                })
235            })
236        })
237        .collect()
238}
239
240/// Whether L0 run `run` may be merged in a stretch that starts at run `start`:
241/// no run ahead of the stretch overlaps it. A table ahead that overlaps a
242/// merged one is newer than the data the two share, so the output, which
243/// takes that data, would belong behind it while belonging ahead of the older
244/// tables behind its inputs, and the output's single recency cannot say both.
245/// This is Pebble's rule for intra-L0 compactions: a merge that takes an older
246/// version of a key takes every newer version of it L0 holds.
247fn clear_of_runs_ahead(ahead: &[Option<usize>], run: usize, start: usize) -> bool {
248    ahead
249        .get(run)
250        .copied()
251        .flatten()
252        .is_none_or(|overlap| overlap >= start)
253}
254
255fn merge_runs<'a>(runs: impl Iterator<Item = &'a RunInfo>, target_size: u64) -> Choice {
256    Choice::Merge(CompactionInput {
257        table_ids: runs.flat_map(|r| r.table_ids.iter().copied()).collect(),
258        dest_level: 0,
259        canonical_level: 0,
260        target_size,
261    })
262}
263
264impl CompactionStrategy for Strategy {
265    fn get_name(&self) -> &'static str {
266        NAME
267    }
268
269    fn get_config(&self) -> Vec<KvPair> {
270        use crate::io::{LittleEndian, WriteBytesExt};
271
272        let mut size_ratio_bytes = vec![];
273        #[expect(clippy::expect_used, reason = "writing into Vec should not fail")]
274        size_ratio_bytes
275            .write_f64::<LittleEndian>(self.size_ratio)
276            .expect("cannot fail");
277
278        vec![
279            (
280                crate::UserKey::from("tiered_size_ratio"),
281                crate::UserValue::from(size_ratio_bytes),
282            ),
283            (
284                crate::UserKey::from("tiered_min_merge_width"),
285                crate::UserValue::from(
286                    #[expect(
287                        clippy::cast_possible_truncation,
288                        reason = "min_merge_width fits in u32 for persistence; usize::MAX maps to u32::MAX"
289                    )]
290                    (self.min_merge_width.min(u32::MAX as usize) as u32).to_le_bytes(),
291                ),
292            ),
293            (
294                crate::UserKey::from("tiered_max_merge_width"),
295                crate::UserValue::from(
296                    #[expect(
297                        clippy::cast_possible_truncation,
298                        reason = "max_merge_width fits in u32 for persistence; usize::MAX maps to u32::MAX"
299                    )]
300                    (self.max_merge_width.min(u32::MAX as usize) as u32).to_le_bytes(),
301                ),
302            ),
303            (
304                crate::UserKey::from("tiered_max_space_amp_pct"),
305                crate::UserValue::from(self.max_space_amplification_percent.to_le_bytes()),
306            ),
307            (
308                crate::UserKey::from("tiered_target_size"),
309                crate::UserValue::from(self.target_size.to_le_bytes()),
310            ),
311        ]
312    }
313
314    /// A size-tiered merge writes back into L0, where its outputs are next
315    /// merged with other L0 runs, not with a level below: no boundaries to
316    /// cut them on.
317    fn next_merge_level(&self, _version: &Version, _dest_level: u8) -> Option<usize> {
318        None
319    }
320
321    fn pending_compaction_bytes(&self, version: &Version) -> u64 {
322        // Tiered debt mirrors the space-amplification trigger in `choose`: the
323        // bytes by which total L0 size exceeds the budget
324        // `largest_run * (1 + max_space_amplification_percent / 100)`. This makes
325        // the bytes-axis backpressure (bytes_slowdown / bytes_stop) engage for
326        // size-tiered trees, not only leveled. All runs are counted, including
327        // any already being compacted: the redundancy is on disk until the full
328        // compaction lands. Zero with fewer than two runs (nothing to reclaim).
329        let l0 = version.l0();
330        let mut total: u128 = 0;
331        let mut largest: u128 = 0;
332        let mut run_count = 0usize;
333        for run in l0.iter() {
334            let size: u128 = run.iter().map(|t| u128::from(Table::file_size(t))).sum();
335            total += size;
336            largest = largest.max(size);
337            run_count += 1;
338        }
339        if run_count < 2 || largest == 0 {
340            return 0;
341        }
342        // Same *100-scaled comparison as `choose` (avoids f64 precision loss);
343        // `rhs` saturates so an effectively unbounded threshold yields zero debt.
344        let lhs = total * 100;
345        let rhs = largest.saturating_mul(100 + u128::from(self.max_space_amplification_percent));
346        // saturating_sub: debt floors at zero by definition (none within budget).
347        let debt = lhs.saturating_sub(rhs) / 100;
348        u64::try_from(debt).unwrap_or(u64::MAX)
349    }
350
351    fn choose(&self, version: &Version, config: &Config, state: &CompactionState) -> Choice {
352        let cmp = config.comparator.as_ref();
353        let all_runs = collect_runs(version, state);
354        let runs: Vec<&RunInfo> = all_runs.iter().flatten().collect();
355
356        if runs.len() < 2 {
357            return Choice::DoNothing;
358        }
359
360        let ahead = frontmost_overlap_ahead(version, cmp);
361
362        // The available runs can merge as one unless a busy run sits between
363        // two of them, or a busy run ahead of them overlaps one.
364        let first_available = all_runs.iter().position(Option::is_some);
365        let last_available = all_runs.iter().rposition(Option::is_some);
366        let available_contiguous = match (first_available, last_available) {
367            (Some(first), Some(last)) => all_runs.get(first..=last).is_some_and(|span| {
368                span.iter().all(Option::is_some)
369                    && (first..=last).all(|run| clear_of_runs_ahead(&ahead, run, first))
370            }),
371            _ => false,
372        };
373
374        // --- Space amplification check ---
375        //
376        // The largest run is treated as the "base" data set. Everything else
377        // is overhead. If overhead exceeds the threshold, compact everything.
378        let total_size: u64 = runs.iter().map(|r| r.size).sum();
379        let largest_run_size = runs.iter().map(|r| r.size).max().unwrap_or(0);
380
381        // A busy run between available ones would split the merge into two
382        // ranges of different age; wait for that compaction to land instead.
383        if largest_run_size > 0 && available_contiguous {
384            // Integer arithmetic to avoid f64 precision loss on large sizes.
385            //   (total / largest - 1) * 100 >= threshold
386            // is equivalent to:
387            //   total * 100 >= largest * (100 + threshold)
388            // `lhs` multiplies a u64-derived u128 by the constant 100, which
389            // cannot overflow u128 — plain multiply. `rhs` multiplies by
390            // `100 + max_space_amplification_percent`, and the percentage is an
391            // unvalidated u64 (tests pass u64::MAX), so that product CAN exceed
392            // u128 — saturate. When `rhs` saturates to u128::MAX the `lhs >= rhs`
393            // check below cannot fire, so a huge amplification bound simply never
394            // triggers a space-amplification compaction, matching the intent of a
395            // very high threshold (tolerate maximum amplification).
396            let lhs = u128::from(total_size) * 100;
397            let rhs = u128::from(largest_run_size)
398                .saturating_mul(100 + u128::from(self.max_space_amplification_percent));
399
400            if lhs >= rhs {
401                return merge_runs(runs.iter().copied(), self.target_size);
402            }
403        }
404
405        // --- Size-ratio triggered merge ---
406        //
407        // Walk L0 from the newest run and take the first stretch of
408        // consecutive available runs whose neighbours have similar sizes and
409        // that no run ahead of it overlaps, as `RocksDB` universal compaction
410        // picks from its newest sorted run
411        // (`UniversalCompactionBuilder::PickCompactionToReduceSortedRuns`).
412        // Merging by size alone could join runs around a differently sized one
413        // between them in age.
414        // Cap at max_merge_width, but still meet min_merge_width (guards
415        // against a misconfigured max < min).
416        let merge_count_for = |stretch: usize| {
417            let count = stretch.min(self.max_merge_width);
418            (count >= self.min_merge_width).then_some(count)
419        };
420        let mut start = 0;
421        while start < all_runs.len() {
422            let mut len = 0;
423            if let Some(Some(first)) = all_runs.get(start)
424                && clear_of_runs_ahead(&ahead, start, start)
425            {
426                // The stretch ends at the first run that is busy, differs in
427                // size from its neighbour, or is overlapped by a run ahead of
428                // `start`: a run the stretch's own runs overlap stays mergeable
429                // with them.
430                let mut prev = first;
431                len = 1;
432                while let Some(Some(next)) = all_runs.get(start + len)
433                    && similar(prev, next, self.size_ratio)
434                    && clear_of_runs_ahead(&ahead, start + len, start)
435                {
436                    prev = next;
437                    len += 1;
438                }
439                if let Some(count) = merge_count_for(len) {
440                    return merge_runs(
441                        all_runs.iter().skip(start).take(count).flatten(),
442                        self.target_size,
443                    );
444                }
445            }
446            // A stretch starting inside this one ends where this one ended,
447            // or sooner, so it is shorter still. A run that could not start a
448            // stretch is passed over alone: the run behind it may.
449            start += len.max(1);
450        }
451
452        Choice::DoNothing
453    }
454}