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;