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}