Skip to main content

lsm_tree/compaction/
work.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026-present, Dmitry Prudnikov
3
4//! What a compaction's work costs, by the kind of work each byte takes:
5//! estimated from metadata before the compaction runs, and measured as it
6//! reads its inputs and installs its outputs.
7//!
8//! A byte of compaction work costs one of four things. A table given another
9//! level where it lies costs a version edit. An encoded unit copied from an
10//! input into an output costs its read and its write, and no decoding. A copy
11//! authenticated or protected again under the output's identity costs that on
12//! top. A value encoded from decoded rows costs the codec as well. The four
13//! are counted apart because a choice among compactions weighs them apart: a
14//! merge that copies most of what it writes costs a fraction of one that
15//! encodes it all, though both move the same bytes.
16
17use crate::comparator::UserComparator;
18use crate::range_tombstone::RangeTombstone;
19use crate::{BlobFile, Table, blob_tree::FragmentationMap};
20use alloc::sync::Arc;
21use portable_atomic::{AtomicU64, Ordering::Relaxed};
22
23#[cfg(feature = "std")]
24use parking_lot::Mutex;
25// no-std: spin mirrors parking_lot's Mutex API without an allocator.
26#[cfg(not(feature = "std"))]
27use spin::Mutex;
28
29/// One figure per kind of compaction work.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct Work<T> {
32    /// Bytes of tables given another level where they lie: the version
33    /// records them there, and nothing is read or written.
34    pub moved: T,
35    /// Encoded bytes copied from an input into an output without being
36    /// decoded: the row groups and column pages a merge left as they were,
37    /// the blocks of a relocated segment, blob frames.
38    pub carried: T,
39    /// Copied bytes authenticated or protected again under the output's
40    /// identity, their payload neither decoded nor compressed again. No
41    /// compaction makes such a copy: the units of an encrypted table, or of
42    /// one with parity, are bound to it, and a merge decodes its rows.
43    pub reprotected: T,
44    /// Output bytes encoded from decoded rows.
45    pub reencoded: T,
46    /// Bytes read from the inputs' files.
47    pub read: T,
48    /// Bytes written to the outputs: the output tables' files and the value
49    /// frames of the output blob files.
50    pub written: T,
51}
52
53impl<T> Work<T> {
54    /// Pairs each figure of `self` with the same figure of `other`.
55    fn zip<U, V>(self, other: Work<U>, f: impl Fn(T, U) -> V) -> Work<V> {
56        Work {
57            moved: f(self.moved, other.moved),
58            carried: f(self.carried, other.carried),
59            reprotected: f(self.reprotected, other.reprotected),
60            reencoded: f(self.reencoded, other.reencoded),
61            read: f(self.read, other.read),
62            written: f(self.written, other.written),
63        }
64    }
65}
66
67/// What a compaction is expected to cost, worked out from metadata before it
68/// runs: no input is read to make it and no compaction filter is called.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub struct WorkEstimate {
71    /// The work expected.
72    pub expected: Work<u64>,
73    /// The most each kind of work can come to, or `None` where nothing known
74    /// before the run bounds it: what a merge reads and writes depends on the
75    /// rows it drops, on what a compaction filter or merge operator makes of
76    /// the rest and on how the output's codec encodes them. The least is zero
77    /// for every kind, since a filter may drop every row.
78    pub ceiling: Work<Option<u64>>,
79}
80
81impl WorkEstimate {
82    /// An estimate of exactly `work`, every figure its own bound.
83    fn exact(work: Work<u64>) -> Self {
84        Self {
85            expected: work,
86            ceiling: work.zip(work, |figure, _| Some(figure)),
87        }
88    }
89
90    /// Whether every figure of `measured` lies within the bounds.
91    #[must_use]
92    pub fn contains(&self, measured: &Work<u64>) -> bool {
93        let within = measured.zip(self.ceiling, |figure, ceiling| {
94            ceiling.is_none_or(|ceiling| figure <= ceiling)
95        });
96        within.moved
97            && within.carried
98            && within.reprotected
99            && within.reencoded
100            && within.read
101            && within.written
102    }
103}
104
105/// A compaction's work, as estimated before it ran and as measured.
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
107pub struct WorkReport {
108    /// The estimate the compaction was planned against.
109    pub estimate: WorkEstimate,
110    /// The work it did: what it read, and what it installed.
111    pub measured: Work<u64>,
112}
113
114impl WorkReport {
115    /// The estimate's error per kind of work: the measured figure less the
116    /// expected one.
117    #[must_use]
118    pub fn error(&self) -> Work<i128> {
119        self.measured
120            .zip(self.estimate.expected, |measured, expected| {
121                i128::from(measured) - i128::from(expected)
122            })
123    }
124}
125
126/// What one install put in the version: the bytes its outputs hold, the part
127/// of them copied rather than encoded, and the bytes read outside the run's
128/// paced input reads.
129#[derive(Clone, Copy, Debug, Default)]
130pub struct Installed {
131    pub written: u64,
132    pub carried: u64,
133    pub read: u64,
134}
135
136/// The work of one compaction run as it goes: the estimate it was planned
137/// against, the bytes its reads returned and what its installs put in the
138/// version. Shared by every thread of the run.
139#[derive(Default)]
140pub struct WorkLedger {
141    estimate: Mutex<WorkEstimate>,
142    moved: AtomicU64,
143    carried: AtomicU64,
144    written: AtomicU64,
145    read: AtomicU64,
146}
147
148impl WorkLedger {
149    /// Records the estimate the run is planned against.
150    pub(crate) fn plan(&self, estimate: WorkEstimate) {
151        *self.estimate.lock() = estimate;
152    }
153
154    /// Records tables given another level where they lie.
155    pub(crate) fn moved(&self, bytes: u64) {
156        self.moved.fetch_add(bytes, Relaxed);
157    }
158
159    /// Records the bytes a read of an input returned.
160    pub(crate) fn read(&self, bytes: u64) {
161        self.read.fetch_add(bytes, Relaxed);
162    }
163
164    /// Records what an install put in the version.
165    pub(crate) fn installed(&self, installed: Installed) {
166        self.written.fetch_add(installed.written, Relaxed);
167        self.carried.fetch_add(installed.carried, Relaxed);
168        self.read.fetch_add(installed.read, Relaxed);
169    }
170
171    /// The run's work so far, against its estimate.
172    pub(crate) fn report(&self) -> WorkReport {
173        let written = self.written.load(Relaxed);
174        let carried = self.carried.load(Relaxed);
175        debug_assert!(
176            carried <= written,
177            "every carried byte is a byte of an output"
178        );
179        WorkReport {
180            estimate: *self.estimate.lock(),
181            measured: Work {
182                moved: self.moved.load(Relaxed),
183                carried,
184                reprotected: 0,
185                // Every carried byte lies in an output the same install
186                // counted as written.
187                reencoded: written - carried,
188                read: self.read.load(Relaxed),
189                written,
190            },
191        }
192    }
193}
194
195/// The pacer of a compaction's input reads. It tells the limiter's latency
196/// backoff how long the reads take and charges nothing: a compaction pays its
197/// limiter for what it writes, and the reads of its inputs only steer the
198/// rate. Active while the limiter has a backoff to steer, so without one the
199/// reads go as they would unpaced. Every read it hears of is counted to the
200/// run's work.
201pub struct InputPacer {
202    // no-std: reads are timed only with std (see `ReadPacer::read_took`)
203    #[cfg(feature = "std")]
204    limiter: Arc<crate::rate_limiter::RateLimiter>,
205    work: Arc<WorkLedger>,
206}
207
208impl InputPacer {
209    /// A pacer steering `limiter` and counting to `work`.
210    pub(crate) fn new(
211        limiter: &Arc<crate::rate_limiter::RateLimiter>,
212        work: &Arc<WorkLedger>,
213    ) -> Self {
214        #[cfg(not(feature = "std"))]
215        let _ = limiter;
216        Self {
217            #[cfg(feature = "std")]
218            limiter: Arc::clone(limiter),
219            work: Arc::clone(work),
220        }
221    }
222}
223
224impl crate::table::util::ReadPacer for InputPacer {
225    fn active(&self) -> bool {
226        #[cfg(feature = "std")]
227        return self.limiter.steers_by_latency();
228        #[cfg(not(feature = "std"))]
229        false
230    }
231
232    fn pace(&self, _offset: u64, _len: u64) {}
233
234    #[cfg(feature = "std")]
235    fn read_took(&self, elapsed: core::time::Duration) {
236        self.limiter.record_read_latency(elapsed);
237    }
238
239    fn read_bytes(&self, len: u64) {
240        self.work.read(len);
241    }
242}
243
244/// The work of giving `tables` another level where they lie.
245pub fn estimate_move<'a>(tables: impl IntoIterator<Item = &'a Table>) -> WorkEstimate {
246    WorkEstimate::exact(Work {
247        moved: tables.into_iter().map(Table::file_len).sum(),
248        ..Work::default()
249    })
250}
251
252/// What a merge's estimate is worked out from.
253pub struct MergePlan<'a> {
254    /// The tables merged.
255    pub inputs: &'a [Table],
256    /// The blob files the merge relocates.
257    pub blob_files: &'a [BlobFile],
258    /// The garbage the version charged to each blob file.
259    pub gc_stats: &'a FragmentationMap,
260    /// Whether the rows pass through user code, a compaction filter or a
261    /// merge operator, that may change any of them.
262    pub user_code: bool,
263    /// The range tombstones the merge deletes the keys they cover by.
264    pub range_tombstones: &'a [RangeTombstone],
265    /// The tree's key order.
266    pub comparator: &'a dyn UserComparator,
267}
268
269/// The work of the merge `plan` describes, with `carries` saying whether the
270/// merge records a table's row groups to copy them.
271///
272/// A table's row groups are expected to be copied when nothing the merge does
273/// can change a row of them: no other input holds a key within its range, it
274/// holds one version per key and no tombstone, no range tombstone the merge
275/// applies covers any of its keys and no user code sees its rows. The rest of
276/// the inputs is expected to be written again whole, and every frame of a
277/// relocated blob file that the version did not charge as garbage copied.
278pub fn estimate_merge(plan: &MergePlan<'_>, carries: impl Fn(&Table) -> bool) -> WorkEstimate {
279    let mut expected = Work::<u64>::default();
280    let mut carried_ceiling = 0;
281    for (at, table) in plan.inputs.iter().enumerate() {
282        let size = table.file_len();
283        expected.written += size;
284        carried_ceiling += size;
285        // The whole file is read by its scan.
286        expected.read += size;
287        if !carries(table) || !unchanged_by_merge(plan, at, table) {
288            continue;
289        }
290        let groups = table.data_section_len();
291        expected.carried += groups;
292        // A copied group is read again, as it lies.
293        expected.read += groups;
294    }
295    for blob_file in plan.blob_files {
296        let frames = blob_file.meta().total_compressed_bytes;
297        let garbage = plan
298            .gc_stats
299            .get(&blob_file.id())
300            .map_or(0, |entry| entry.on_disk_bytes);
301        // A file charged past its frames holds no live frame: zero is the
302        // answer, not a bound hit.
303        let live = frames.saturating_sub(garbage);
304        expected.carried += live;
305        expected.written += live;
306        expected.read += frames;
307        carried_ceiling += frames;
308    }
309    // Every copied byte is one of the bytes expected written.
310    expected.reencoded = expected.written - expected.carried;
311    WorkEstimate {
312        expected,
313        ceiling: Work {
314            moved: Some(0),
315            // A copy takes a unit of an input as it lies, each at most once.
316            carried: Some(carried_ceiling),
317            reprotected: Some(0),
318            reencoded: None,
319            read: None,
320            written: None,
321        },
322    }
323}
324
325/// Whether the merge `plan` describes leaves every row of `table`, its input
326/// `at`, as it is.
327fn unchanged_by_merge(plan: &MergePlan<'_>, at: usize, table: &Table) -> bool {
328    let meta = &table.metadata;
329    let (min, max) = (meta.key_range.min(), meta.key_range.max());
330    let below = |a: &[u8], b: &[u8]| plan.comparator.compare(a, b) == core::cmp::Ordering::Less;
331    !plan.user_code
332        && meta.tombstone_count == 0
333        && meta.weak_tombstone_count == 0
334        && meta.range_tombstone_count == 0
335        && meta.key_count == Some(meta.item_count)
336        && plan.inputs.iter().enumerate().all(|(other_at, other)| {
337            let other = &other.metadata.key_range;
338            other_at == at || below(other.max(), min) || below(max, other.min())
339        })
340        // A tombstone covers `start <= key < end`.
341        && plan
342            .range_tombstones
343            .iter()
344            .all(|rt| below(max, &rt.start) || !below(min, &rt.end))
345}
346
347#[cfg(test)]
348mod tests;