Skip to main content

turbo_persistence/compaction/
selector.rs

1//! Chooses which SST files to merge.
2//!
3//! The key space of each family is split into shards (see [`crate::shard`]).  Divide the SST files
4//! into disjoint components (mostly this is Per shard but during shard resizes SST files can span
5//! shards). For each 'component' of files we consider two kinds of merges
6//!
7//! - A bottom merge merges all files of the shard into a new bottom run. It drops all superseded
8//!   entries and all tombstones, so it bounds the space amplification: it runs when the files above
9//!   the bottom run are larger than `max_space_amplification_percent` of the bottom run. A
10//!   tombstone is small, but deletes an entry of the bottom run, so it counts as an average bottom
11//!   entry.
12//! - An intermediate merge merges files above the bottom run, when there are more than
13//!   `max_files_above_bottom` of them. This bounds the number of files a lookup has to consult
14//!   without rewriting the bottom run. It merges the newest files of similar size (see
15//!   `size_ratio_percent`), so that small files, e.g. from small commits, are merged with each
16//!   other without rewriting a larger file written before them.
17//!
18//! Bottom merges are paced by the fresh bytes (written by commits and not compacted yet) of the
19//! family: they are scheduled until they rewrote `rewrite_per_fresh_byte` times the fresh bytes, so
20//! that the cost of compaction follows the amount of new data.
21//!
22//! A shard that is far behind, over twice the space amplification trigger or with over twice
23//! `max_files_above_bottom` files above its bottom run, is bottom merged even when the rewrite
24//! quota is reached. That bounds how far a shard can fall behind if the quota is too conservative
25//!
26//! If a 'component' spans multiple shards it will always be split across shard boundaries when
27//! re-written.
28
29use std::ops::RangeInclusive;
30
31use crate::shard::ShardBits;
32
33/// Represents part of a database (i.e. an SST file) with a range of keys (i.e. hashes) and a size
34/// of that data in bytes.
35pub trait Compactable {
36    /// The range of keys stored in this database segment.
37    fn range(&self) -> RangeInclusive<u64>;
38
39    /// The size of the compactable database segment in bytes.
40    fn size(&self) -> u64;
41
42    /// Whether the segment is part of the bottom run of its shard.
43    fn is_bottom(&self) -> bool;
44
45    /// Whether the segment was written by a commit and not compacted yet.
46    fn is_fresh(&self) -> bool;
47
48    /// The number of entries in the segment.
49    fn entry_count(&self) -> u64;
50
51    /// The number of tombstones in the segment.
52    fn tombstone_count(&self) -> u64;
53}
54
55/// Configuration for the compaction algorithm.
56#[derive(Clone, Copy, Debug)]
57pub struct CompactConfig {
58    /// A shard is merged into a new bottom run when the files above its bottom run are larger than
59    /// this percentage of the bottom run. E.g. `50` merges a shard with a 100MB bottom run once
60    /// more than 50MB are above it. `0` merges every shard that has files above its bottom run.
61    pub max_space_amplification_percent: u16,
62
63    /// A shard is only merged into a new bottom run when at least this many bytes are above its
64    /// bottom run. This is to avoid tiny families that always rewrite everything doing constant
65    /// full merges and starving other work.
66    pub min_bottom_merge_bytes: u64,
67
68    /// The files above the bottom run of a shard are merged when there are more than this many
69    /// (at least one, since merging a single file would only move it).
70    pub max_files_above_bottom: usize,
71
72    /// An intermediate merge takes the newest file above the bottom run, and then each next older
73    /// one that is at most this percentage larger than the files taken so far, but at least the
74    /// two newest. E.g. with `100`, a file joins while it's at most twice the size of the newer
75    /// files combined. This is the size ratio of RocksDB's universal compaction.
76    pub size_ratio_percent: u16,
77
78    /// Bottom merges of a family are scheduled until they rewrote this many bytes per byte of its
79    /// fresh files; the last one may go past it, and the first one always runs. E.g. with `3.0`,
80    /// after commits wrote 10MB to a family, no more bottom merges are scheduled once they rewrote
81    /// 30MB, except for shards far behind.
82    pub rewrite_per_fresh_byte: f32,
83
84    /// The maximum number of merge jobs in a compaction, across all families. Merge jobs run in
85    /// parallel, so this bounds the work of a compaction. It doesn't limit the number of files
86    /// merged by a job.
87    pub max_merge_jobs: usize,
88}
89
90impl Default for CompactConfig {
91    fn default() -> Self {
92        Self {
93            max_space_amplification_percent: 50,
94            min_bottom_merge_bytes: 1024 * 1024,
95            max_files_above_bottom: 6,
96            rewrite_per_fresh_byte: 3.0,
97            size_ratio_percent: 100,
98            max_merge_jobs: 8,
99        }
100    }
101}
102
103impl CompactConfig {
104    /// A config that merges every shard with files above its bottom run into a new bottom run.
105    pub fn full() -> Self {
106        Self {
107            max_space_amplification_percent: 0,
108            min_bottom_merge_bytes: 0,
109            // Irrelevant, as bottom merges are always chosen.
110            max_files_above_bottom: usize::MAX,
111            rewrite_per_fresh_byte: f32::INFINITY,
112            size_ratio_percent: u16::MAX,
113            max_merge_jobs: usize::MAX,
114        }
115    }
116}
117
118/// A set of compactables to merge.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct MergeJob {
121    /// Indices of the compactables, in ascending (read) order.
122    pub members: Vec<usize>,
123    /// Whether the job merges all files of its shards, i.e. writes a new bottom run.
124    pub bottom: bool,
125}
126
127/// A planned merge job and how urgent it is, before merge jobs are selected across families.
128struct Candidate {
129    job: MergeJob,
130    /// How far the shard is over the limit that triggers the job, e.g. `2.0` for twice the allowed
131    /// space amplification or file count. Higher is more urgent.
132    priority: f32,
133}
134
135/// Groups the compactables into components, sets of shards that are merged together.
136///
137/// Normally a compactable is within a single shard and a component is one shard. But when the
138/// shard count of a family grows, compactables written before cover multiple of the new shards.
139/// Such a compactable can only be merged together with everything else in the shards it covers, so
140/// all shards it covers belong to one component. Returns the members of each component in
141/// ascending order.
142fn components<T: Compactable>(compactables: &[T], shard_bits: ShardBits) -> Vec<Vec<usize>> {
143    let mut spans = compactables
144        .iter()
145        .enumerate()
146        .map(|(index, c)| {
147            let range = c.range();
148            let shard_start = shard_bits.shard_of(*range.start());
149            let shard_end = shard_bits.shard_of(*range.end());
150            (shard_start, shard_end, index)
151        })
152        .collect::<Vec<_>>();
153    spans.sort_unstable();
154    let mut result: Vec<(u32, Vec<usize>)> = Vec::new();
155    for (shard_start, shard_end, index) in spans {
156        match result.last_mut() {
157            Some((component_end, members)) if shard_start <= *component_end => {
158                // Grow overlapping compactables by adding a new member and possibly expanding the
159                // component
160                *component_end = (*component_end).max(shard_end);
161                members.push(index);
162            }
163            _ => result.push((shard_end, vec![index])),
164        }
165    }
166    result
167        .into_iter()
168        .map(|(_, mut members)| {
169            members.sort_unstable();
170            members
171        })
172        .collect()
173}
174
175/// Produces a [`Candidate`] for an intermediate merge of the files `above` the bottom run of a
176/// shard (ascending, i.e. oldest first). Intermediate merges are ranked by how far the number of
177/// files above the bottom run exceeds `max_files_above_bottom`.
178///
179/// The merge takes the newest files, and then each next older one while it's at most
180/// `size_ratio_percent` larger than the files taken so far, but at least the two newest. It always
181/// takes the newest files, so its output can be placed after all files, and the older files it
182/// leaves out stay older than it.
183///
184/// `above_is_all_shard_files` is true when the shard has no bottom run, so `above` holds all of its
185/// files. A merge that takes all of them then writes the shard's bottom run.
186fn intermediate_candidate<T: Compactable>(
187    compactables: &[T],
188    above: Vec<usize>,
189    above_is_all_shard_files: bool,
190    config: &CompactConfig,
191) -> Candidate {
192    let priority = above.len() as f32 / config.max_files_above_bottom.max(1) as f32;
193    let mut taken = 0u64;
194    let mut first = above.len();
195    // Enough of the newest files to get back to `max_files_above_bottom`, and at least two.
196    let min_to_take = (above.len() + 1)
197        .saturating_sub(config.max_files_above_bottom.max(1))
198        .max(2);
199    while first > 0 {
200        let size = compactables[above[first - 1]].size();
201        let taken_files = above.len() - first;
202        // We have to merge at least 2 files or enough to take the merge below the
203        // max_files_above_bottom limit
204        if taken_files >= min_to_take {
205            // Once we have taken enough, keep taking to maintain the size ratio
206            let limit = taken.saturating_mul(100 + u64::from(config.size_ratio_percent));
207            if size.saturating_mul(100) > limit {
208                break;
209            }
210        }
211        taken += size;
212        first -= 1;
213    }
214    let mut above = above;
215    above.drain(..first);
216    Candidate {
217        priority,
218        job: MergeJob {
219            members: above,
220            // Merging all files of a shard without a bottom run rewrites as much as a bottom merge,
221            // so make the result its bottom run.
222            bottom: above_is_all_shard_files && first == 0,
223        },
224    }
225}
226
227/// Plans the merge jobs of one family, bottom merges first, each kind most urgent first.
228fn plan_family<T: Compactable>(
229    compactables: &[T],
230    shard_bits: ShardBits,
231    config: &CompactConfig,
232) -> Vec<Candidate> {
233    let fresh_bytes = compactables
234        .iter()
235        .filter(|c| c.is_fresh())
236        .map(|c| c.size())
237        .sum::<u64>();
238
239    let mut bottom_candidates = Vec::new();
240    let mut intermediate_candidates = Vec::new();
241    for members in components(compactables, shard_bits) {
242        let (bottom, above): (Vec<usize>, Vec<usize>) =
243            members.iter().partition(|&&i| compactables[i].is_bottom());
244        if above.is_empty() {
245            continue;
246        }
247        // A bottom file is older than the files above it that overlap it. After the shard count
248        // shrank, a component can also hold a newer bottom file of another former shard, which
249        // covers different keys.
250        debug_assert!(
251            bottom.iter().all(|&b| above.iter().all(|&a| {
252                let (b_range, a_range) = (compactables[b].range(), compactables[a].range());
253                b < a || b_range.end() < a_range.start() || a_range.end() < b_range.start()
254            })),
255            "bottom files must be older than the files above them that they overlap"
256        );
257        // The bottom run consists of multiple files when the bottom merge output exceeds the size
258        // of a file, or when the component spans multiple shards after the shard count grew.
259        let bottom_bytes = bottom.iter().map(|&i| compactables[i].size()).sum::<u64>();
260        let above_bytes = above.iter().map(|&i| compactables[i].size()).sum::<u64>();
261        // A bottom merge drops the entries deleted by tombstones above the bottom run, which are
262        // much larger than the tombstones. Estimate them with the average entry size of the bottom
263        // run, which has no tombstones. This overestimates, as tombstones can delete entries above
264        // the bottom run, or nothing, and entries above the bottom run shadow each other, but it
265        // makes a shard with many deletes merge, which it wouldn't by size alone.
266        let bottom_entries = bottom
267            .iter()
268            .map(|&i| compactables[i].entry_count())
269            .sum::<u64>();
270        let above_tombstones = above
271            .iter()
272            .map(|&i| compactables[i].tombstone_count())
273            .sum::<u64>();
274        let deleted_bytes = bottom_bytes
275            .checked_div(bottom_entries)
276            .unwrap_or(0)
277            .saturating_mul(above_tombstones);
278        // Both the files above the bottom run and the entries they delete are garbage that a
279        // bottom merge reclaims.
280        let reclaimable_bytes = above_bytes.saturating_add(deleted_bytes);
281        let limit = f64::from(config.max_space_amplification_percent) / 100.0;
282        let amplification = if bottom_bytes == 0 {
283            f64::INFINITY
284        } else {
285            reclaimable_bytes as f64 / bottom_bytes as f64
286        };
287        if amplification > limit && reclaimable_bytes >= config.min_bottom_merge_bytes {
288            let candidate = Candidate {
289                job: MergeJob {
290                    members,
291                    bottom: true,
292                },
293                priority: if limit > 0.0 {
294                    (amplification / limit) as f32
295                } else {
296                    f32::INFINITY
297                },
298            };
299            // Far behind: bottom merged even when the rewrite quota is reached. A trigger of 0
300            // merges every shard anyway, and then only the quota limits the rewrites.
301            let overdue = limit > 0.0
302                && (amplification > 2.0 * limit || above.len() > 2 * config.max_files_above_bottom);
303            bottom_candidates.push((
304                candidate,
305                bottom_bytes + above_bytes,
306                above,
307                overdue,
308                bottom.is_empty(),
309            ));
310        } else if above.len() > config.max_files_above_bottom.max(1) {
311            intermediate_candidates.push(intermediate_candidate(
312                compactables,
313                above,
314                /* above_is_all_shard_files= */ bottom.is_empty(),
315                config,
316            ));
317        }
318    }
319
320    // Schedule the most amplified shards until the rewrite quota is reached. Shards past it still
321    // get an intermediate merge if they have too many files.
322    bottom_candidates.sort_by(|a, b| b.0.priority.total_cmp(&a.0.priority));
323    let mut rewrite_target = if config.rewrite_per_fresh_byte.is_finite() {
324        // Float to int casts saturate.
325        (f64::from(config.rewrite_per_fresh_byte) * fresh_bytes as f64) as u64
326    } else {
327        // Not `INFINITY * fresh_bytes`, which is NaN (a quota of 0) without fresh bytes.
328        u64::MAX
329    };
330    let mut result = Vec::new();
331    for (candidate, cost, above, overdue, above_is_all_shard_files) in bottom_candidates {
332        // The first job always runs, even when the quota is 0, and we keep scheduling jobs while
333        // the quota isn't reached, so that we don't pathologically rewrite too little.
334        if result.is_empty() || rewrite_target > 0 || overdue {
335            rewrite_target = rewrite_target.saturating_sub(cost);
336            result.push(candidate);
337        } else if above.len() > config.max_files_above_bottom.max(1) {
338            // The quota is reached, but an intermediate merge of the same shard is cheap and
339            // still bounds the number of files a lookup consults.
340            intermediate_candidates.push(intermediate_candidate(
341                compactables,
342                above,
343                above_is_all_shard_files,
344                config,
345            ));
346        }
347    }
348    intermediate_candidates.sort_by(|a, b| b.priority.total_cmp(&a.priority));
349    result.extend(intermediate_candidates);
350    result
351}
352
353/// Plans the merge jobs for all families, limited to `max_merge_jobs` jobs in total. Each family is
354/// given as its compactables, in the order they are read (oldest first), and its shards.
355///
356/// Returns the merge jobs of each family. The jobs of a family don't overlap each other, and each
357/// job includes the 'newest' SSTs. So the outputs can be placed after all existing files without
358/// moving any file.
359pub fn plan_compaction<T: Compactable>(
360    families: &[(&[T], ShardBits)],
361    config: &CompactConfig,
362) -> Vec<Vec<MergeJob>> {
363    let mut candidates = families
364        .iter()
365        .enumerate()
366        .flat_map(|(family, (compactables, shard_bits))| {
367            plan_family(compactables, *shard_bits, config)
368                .into_iter()
369                .enumerate()
370                .map(move |(order, candidate)| (family, order, candidate))
371        })
372        .collect::<Vec<_>>();
373    // Most over its limit first, so that intermediate merges, which bound the files a lookup
374    // consults, are not starved by bottom merges. Ties prefer bottom merges, then keep each
375    // family's order, then family order.
376    candidates.sort_by(|a, b| {
377        b.2.priority
378            .total_cmp(&a.2.priority)
379            .then(b.2.job.bottom.cmp(&a.2.job.bottom))
380            .then(a.1.cmp(&b.1))
381            .then(a.0.cmp(&b.0))
382    });
383    let mut result = vec![Vec::new(); families.len()];
384    for (family, _, candidate) in candidates.into_iter().take(config.max_merge_jobs) {
385        result[family].push(candidate.job);
386    }
387    for jobs in &mut result {
388        jobs.sort_by_key(|job| job.members[0]);
389    }
390    result
391}
392
393#[cfg(test)]
394mod tests {
395    use std::collections::{BTreeMap, BTreeSet};
396
397    use rand::{RngExt, SeedableRng, seq::SliceRandom};
398
399    use super::*;
400
401    #[derive(Clone, Debug)]
402    struct File {
403        range: RangeInclusive<u64>,
404        size: u64,
405        bottom: bool,
406        fresh: bool,
407    }
408
409    impl Compactable for File {
410        fn range(&self) -> RangeInclusive<u64> {
411            self.range.clone()
412        }
413
414        fn size(&self) -> u64 {
415            self.size
416        }
417
418        fn is_bottom(&self) -> bool {
419            self.bottom
420        }
421
422        fn is_fresh(&self) -> bool {
423            self.fresh
424        }
425
426        fn entry_count(&self) -> u64 {
427            0
428        }
429
430        fn tombstone_count(&self) -> u64 {
431            0
432        }
433    }
434
435    fn file(shard: u32, shard_bits: u8, size: u64, bottom: bool, fresh: bool) -> File {
436        File {
437            range: ShardBits::new(shard_bits).range(shard),
438            size,
439            bottom,
440            fresh,
441        }
442    }
443
444    /// The default config without the byte floor, since test files are tiny, and with the file
445    /// count limit the tests are written for, so they don't depend on the default.
446    fn test_config() -> CompactConfig {
447        CompactConfig {
448            min_bottom_merge_bytes: 0,
449            max_files_above_bottom: 4,
450            ..Default::default()
451        }
452    }
453
454    fn plan(files: &[File], shard_bits: u8, config: &CompactConfig) -> Vec<(Vec<usize>, bool)> {
455        plan_compaction(&[(files, ShardBits::new(shard_bits))], config)
456            .remove(0)
457            .into_iter()
458            .map(|job| (job.members, job.bottom))
459            .collect()
460    }
461
462    #[test]
463    fn test_shard_without_bottom_run_gets_one() {
464        let files = [file(0, 0, 100, false, true), file(0, 0, 100, false, true)];
465        assert_eq!(plan(&files, 0, &test_config()), vec![(vec![0, 1], true)]);
466    }
467
468    #[test]
469    fn test_bottom_merge_by_space_amplification() {
470        let config = test_config();
471        // 40% above the bottom run: nothing to do.
472        let files = [file(0, 0, 1000, true, false), file(0, 0, 400, false, true)];
473        assert_eq!(plan(&files, 0, &config), vec![]);
474        // 60%: merge the shard into a new bottom run.
475        let files = [file(0, 0, 1000, true, false), file(0, 0, 600, false, true)];
476        assert_eq!(plan(&files, 0, &config), vec![(vec![0, 1], true)]);
477    }
478
479    #[test]
480    fn test_intermediate_merge_by_file_count() {
481        let mut files = vec![file(0, 0, 1000, true, false)];
482        files.extend((0..5).map(|_| file(0, 0, 10, false, true)));
483        assert_eq!(
484            plan(&files, 0, &test_config()),
485            vec![(vec![1, 2, 3, 4, 5], false)]
486        );
487        files.pop();
488        assert_eq!(plan(&files, 0, &test_config()), vec![]);
489    }
490
491    #[test]
492    fn test_intermediate_merge_takes_the_newest_files_of_similar_size() {
493        // A large file from a big commit, then five small files from small commits: only the small
494        // files are merged.
495        let mut files = vec![
496            file(0, 0, 10_000, true, false),
497            file(0, 0, 1000, false, true),
498        ];
499        files.extend((0..5).map(|_| file(0, 0, 10, false, true)));
500        assert_eq!(
501            plan(&files, 0, &test_config()),
502            vec![(vec![2, 3, 4, 5, 6], false)]
503        );
504        // Similar sizes are all merged, see `test_intermediate_merge_by_file_count`. With sizes
505        // that grow steeply towards older files, at least the two newest are merged.
506        let mut files = vec![file(0, 0, 100_000, true, false)];
507        files.extend([10_000, 1000, 100, 10, 1].map(|size| file(0, 0, size, false, true)));
508        assert_eq!(plan(&files, 0, &test_config()), vec![(vec![4, 5], false)]);
509        // A file joins while it's at most twice the size of the newer files combined: the two
510        // newest make 20, then 20 joins (40), then 40 joins (80), then 500 > 2 * 80 stops.
511        let mut files = vec![file(0, 0, 10_000, true, false)];
512        files.extend([500, 40, 20, 10, 10].map(|size| file(0, 0, size, false, true)));
513        assert_eq!(
514            plan(&files, 0, &test_config()),
515            vec![(vec![2, 3, 4, 5], false)]
516        );
517    }
518
519    #[test]
520    fn test_budget_spreads_bottom_merges() {
521        // All four shards are above the threshold, but the fresh data only pays for one bottom
522        // merge. The most amplified shard goes first.
523        let mut files = (0..4)
524            .map(|shard| file(shard, 2, 1000, true, false))
525            .collect::<Vec<_>>();
526        files.extend((0..4).map(|shard| file(shard, 2, 600 + u64::from(shard), false, true)));
527        let config = CompactConfig {
528            rewrite_per_fresh_byte: 0.1,
529            ..test_config()
530        };
531        assert_eq!(plan(&files, 2, &config), vec![(vec![3, 7], true)]);
532        // With a large enough quota, all of them are merged.
533        assert_eq!(
534            plan(&files, 2, &test_config()),
535            vec![
536                (vec![0, 4], true),
537                (vec![1, 5], true),
538                (vec![2, 6], true),
539                (vec![3, 7], true)
540            ]
541        );
542    }
543
544    #[test]
545    fn test_full_config_merges_every_shard_without_fresh_bytes() {
546        // Nothing is fresh (everything was compacted before), so the quota is 0 for a finite
547        // factor, but the full config has no quota.
548        let mut files = (0..4)
549            .map(|shard| file(shard, 2, 1000, true, false))
550            .collect::<Vec<_>>();
551        files.extend((0..4).map(|shard| file(shard, 2, 10, false, false)));
552        assert_eq!(plan(&files, 2, &CompactConfig::full()).len(), 4);
553        // With a finite factor, only the first job runs (the full config's trigger is 0, so no
554        // shard counts as far behind).
555        let config = CompactConfig {
556            rewrite_per_fresh_byte: 3.0,
557            ..CompactConfig::full()
558        };
559        assert_eq!(plan(&files, 2, &config).len(), 1);
560    }
561
562    #[test]
563    fn test_quota_stops_when_exactly_reached() {
564        // Four shards, each a 360 byte bottom file with 140 compacted and 100 fresh bytes above it
565        // (67%, over the trigger but under twice it), so each job rewrites 600 bytes. The quota is
566        // 3 * 400 fresh bytes = 1200, which two jobs reach exactly: a third one doesn't run.
567        let mut files = (0..4)
568            .map(|shard| file(shard, 2, 360, true, false))
569            .collect::<Vec<_>>();
570        files.extend((0..4).map(|shard| file(shard, 2, 140, false, false)));
571        files.extend((0..4).map(|shard| file(shard, 2, 100, false, true)));
572        assert_eq!(plan(&files, 2, &test_config()).len(), 2);
573    }
574
575    #[test]
576    fn test_shards_far_behind_are_merged_over_the_quota() {
577        // Four shards over the trigger with nothing fresh, so the quota allows only the first
578        // bottom merge: at 60% and 70% (under twice the trigger), 110% (over twice the trigger),
579        // and 60% with 9 files above the bottom run (over twice `max_files_above_bottom`).
580        let mut files = (0..4)
581            .map(|shard| file(shard, 2, 1000, true, false))
582            .collect::<Vec<_>>();
583        files.extend(
584            [(0, 600), (1, 700), (2, 1100)].map(|(shard, size)| file(shard, 2, size, false, false)),
585        );
586        files.extend((0..9).map(|_| file(3, 2, 600 / 9 + 1, false, false)));
587        let config = CompactConfig {
588            rewrite_per_fresh_byte: 0.0,
589            ..test_config()
590        };
591        let bottom_merged = plan(&files, 2, &config)
592            .into_iter()
593            .filter(|(_, bottom)| *bottom)
594            .map(|(members, _)| members[0])
595            .collect::<Vec<_>>();
596        // Shard 2 (most amplified, and over twice the trigger) runs first and shard 3 is over twice
597        // the file count; shards 0 and 1 wait for quota.
598        assert_eq!(bottom_merged, vec![2, 3]);
599    }
600
601    #[test]
602    fn test_byte_floor_skips_bottom_merges_of_tiny_shards() {
603        // Every commit rewrites the whole (tiny) family: always amplified, but not worth a bottom
604        // merge. It only gets an intermediate merge once there are enough files.
605        let mut files = vec![file(0, 0, 50, true, false)];
606        files.push(file(0, 0, 50, false, true));
607        let config = CompactConfig {
608            min_bottom_merge_bytes: 1000,
609            ..test_config()
610        };
611        assert_eq!(plan(&files, 0, &config), vec![]);
612        files.extend((0..4).map(|_| file(0, 0, 50, false, true)));
613        assert_eq!(plan(&files, 0, &config), vec![(vec![1, 2, 3, 4, 5], false)]);
614    }
615
616    #[test]
617    fn test_intermediate_merges_are_not_starved() {
618        // One job slot: a family with far too many files above its bottom run wins over a family
619        // that is only slightly over the space amplification threshold.
620        let mut many_files = vec![file(0, 0, 1000, true, false)];
621        many_files.extend((0..8).map(|_| file(0, 0, 1, false, true)));
622        let amplified = [file(0, 0, 1000, true, false), file(0, 0, 600, false, true)];
623        let config = CompactConfig {
624            max_merge_jobs: 1,
625            ..test_config()
626        };
627        let jobs = plan_compaction(
628            &[
629                (&many_files[..], ShardBits::new(0)),
630                (&amplified[..], ShardBits::new(0)),
631            ],
632            &config,
633        );
634        assert_eq!(jobs[0].len(), 1);
635        assert!(!jobs[0][0].bottom);
636        assert!(jobs[1].is_empty());
637    }
638
639    #[test]
640    fn test_coarse_files_merge_the_shards_they_span() {
641        // A bottom run written with 2 shards, and fresh files written with 4 shards.
642        let files = [
643            file(0, 1, 1000, true, false),
644            file(1, 1, 1000, true, false),
645            file(0, 2, 600, false, true),
646            file(1, 2, 10, false, true),
647            file(3, 2, 10, false, true),
648        ];
649        assert_eq!(plan(&files, 2, &test_config()), vec![(vec![0, 2, 3], true)]);
650    }
651
652    #[test]
653    fn test_merging_all_files_of_a_shard_without_bottom_run_makes_a_bottom_run() {
654        // Too few bytes for a bottom merge, but too many files: the intermediate merge takes all of
655        // them, so it writes the shard's bottom run.
656        let files = (0..5)
657            .map(|_| file(0, 0, 10, false, true))
658            .collect::<Vec<_>>();
659        let config = CompactConfig {
660            min_bottom_merge_bytes: 1000,
661            ..test_config()
662        };
663        assert_eq!(plan(&files, 0, &config), vec![(vec![0, 1, 2, 3, 4], true)]);
664        // With a bottom run, it stays an intermediate merge.
665        let mut files = vec![file(0, 0, 10, true, false)];
666        files.extend((0..5).map(|_| file(0, 0, 10, false, true)));
667        assert_eq!(plan(&files, 0, &config), vec![(vec![1, 2, 3, 4, 5], false)]);
668    }
669
670    #[test]
671    fn test_merge_job_limit_across_families() {
672        let a = [file(0, 0, 100, false, true), file(0, 0, 100, false, true)];
673        let b = [file(0, 0, 100, false, true), file(0, 0, 100, false, true)];
674        let config = CompactConfig {
675            max_merge_jobs: 1,
676            ..test_config()
677        };
678        let jobs = plan_compaction(
679            &[(&a[..], ShardBits::new(0)), (&b[..], ShardBits::new(0))],
680            &config,
681        );
682        assert_eq!(jobs.iter().map(Vec::len).sum::<usize>(), 1);
683    }
684
685    /// A simulated SST file.
686    struct Container {
687        /// Sorted keys, and whether the entry is a tombstone.
688        keys: Vec<(u64, bool)>,
689        bottom: bool,
690        fresh: bool,
691    }
692
693    impl Compactable for Container {
694        fn range(&self) -> RangeInclusive<u64> {
695            self.keys[0].0..=self.keys.last().unwrap().0
696        }
697
698        fn size(&self) -> u64 {
699            self.keys.len() as u64
700        }
701
702        fn is_bottom(&self) -> bool {
703            self.bottom
704        }
705
706        fn is_fresh(&self) -> bool {
707            self.fresh
708        }
709
710        fn entry_count(&self) -> u64 {
711            self.keys.len() as u64
712        }
713
714        fn tombstone_count(&self) -> u64 {
715            self.keys.iter().filter(|(_, tombstone)| *tombstone).count() as u64
716        }
717    }
718
719    /// Splits sorted entries at shard boundaries, like commits and merges do.
720    fn split_by_shard(
721        entries: Vec<(u64, bool)>,
722        shard_bits: ShardBits,
723        bottom: bool,
724        fresh: bool,
725    ) -> Vec<Container> {
726        let mut by_shard = BTreeMap::<u32, Vec<(u64, bool)>>::new();
727        for entry in entries {
728            by_shard
729                .entry(shard_bits.shard_of(entry.0))
730                .or_default()
731                .push(entry);
732        }
733        by_shard
734            .into_values()
735            .map(|keys| Container {
736                keys,
737                bottom,
738                fresh,
739            })
740            .collect()
741    }
742
743    /// Runs the merge jobs like the database does. Returns the number of entries written.
744    fn run_jobs(
745        containers: &mut Vec<Container>,
746        jobs: Vec<MergeJob>,
747        shard_bits: ShardBits,
748    ) -> u64 {
749        let mut written = 0;
750        let mut outputs = Vec::new();
751        let mut merged = BTreeSet::new();
752        for job in jobs {
753            let oldest = job.members[0];
754            // Newer entries win.
755            let mut entries = job
756                .members
757                .iter()
758                .rev()
759                .flat_map(|&i| containers[i].keys.iter().copied())
760                .collect::<Vec<_>>();
761            entries.sort_by_key(|(key, _)| *key);
762            entries.dedup_by_key(|(key, _)| *key);
763            // Tombstones are dropped when no older file holds the key.
764            entries.retain(|&(key, tombstone)| {
765                !tombstone
766                    || containers[..oldest]
767                        .iter()
768                        .any(|c| c.keys.binary_search_by_key(&key, |(k, _)| *k).is_ok())
769            });
770            written += entries.len() as u64;
771            outputs.extend(split_by_shard(entries, shard_bits, job.bottom, false));
772            merged.extend(job.members);
773        }
774        let mut i = 0;
775        containers.retain(|_| {
776            i += 1;
777            !merged.contains(&(i - 1))
778        });
779        containers.extend(outputs);
780        written
781    }
782
783    const KEY_COUNT: u64 = 20_000;
784    /// Spreads keys over the hash space like real key hashes.
785    const KEY_SCALE: u64 = u64::MAX / KEY_COUNT;
786
787    struct SimulationResult {
788        compactions_with_bottom_merges: usize,
789        /// Entries written by commits and by compactions.
790        written: u64,
791        rewritten: u64,
792        /// The highest space amplification (stored / live entries) after a compaction, once
793        /// everything was compacted once.
794        max_space_amplification: f64,
795        /// The most files a lookup has to consult, after a compaction.
796        max_files_per_shard: usize,
797    }
798
799    /// A commit rewrites a churning set of hot keys and deletes some cold keys, like a build with
800    /// garbage collection. The database is compacted after every commit.
801    /// Runs one compaction. Returns the entries written and whether it had bottom merges.
802    fn compact(
803        containers: &mut Vec<Container>,
804        config: &CompactConfig,
805        shard_bits: ShardBits,
806    ) -> (u64, bool) {
807        let jobs = plan_compaction(&[(&containers[..], shard_bits)], config).remove(0);
808        let bottom = jobs.iter().any(|job| job.bottom);
809        (run_jobs(containers, jobs, shard_bits), bottom)
810    }
811
812    /// After each commit like a build, `small_commits` commits rewrite a few hot keys each, like
813    /// snapshots of a development session. The database is compacted after every commit.
814    fn simulate(
815        config: &CompactConfig,
816        shard_bits: ShardBits,
817        iterations: usize,
818        small_commits: usize,
819    ) -> SimulationResult {
820        let mut rnd = rand::rngs::SmallRng::from_seed([0; 32]);
821        let mut live = (0..KEY_COUNT).map(|k| k * KEY_SCALE).collect::<Vec<_>>();
822        live.shuffle(&mut rnd);
823        let mut containers = split_by_shard(
824            {
825                let mut all = live.iter().map(|&k| (k, false)).collect::<Vec<_>>();
826                all.sort_unstable();
827                all
828            },
829            shard_bits,
830            false,
831            true,
832        );
833        let mut written = KEY_COUNT;
834        let mut rewritten = 0;
835        let mut result = SimulationResult {
836            compactions_with_bottom_merges: 0,
837            written: 0,
838            rewritten: 0,
839            max_space_amplification: 0.0,
840            max_files_per_shard: 0,
841        };
842        let hot_count = KEY_COUNT as usize / 20;
843        for iteration in 0..iterations {
844            let (entries, bottom) = compact(&mut containers, config, shard_bits);
845            rewritten += entries;
846            if bottom {
847                result.compactions_with_bottom_merges += 1;
848            }
849            if iteration >= iterations / 4 {
850                let stored = containers.iter().map(|c| c.keys.len()).sum::<usize>();
851                result.max_space_amplification = result
852                    .max_space_amplification
853                    .max(stored as f64 / live.len() as f64);
854                for shard in 0..shard_bits.count() {
855                    let range = shard_bits.range(shard);
856                    let files = containers
857                        .iter()
858                        .filter(|c| {
859                            let r = c.range();
860                            r.start() <= range.end() && range.start() <= r.end()
861                        })
862                        .count();
863                    result.max_files_per_shard = result.max_files_per_shard.max(files);
864                }
865            }
866
867            // Rewrite the hot keys (the first part of `live`) and churn the hot set a bit.
868            let mut commit = live[..hot_count]
869                .iter()
870                .map(|&k| (k, false))
871                .collect::<Vec<_>>();
872            for _ in 0..hot_count / 10 {
873                let i = rnd.random_range(0..hot_count);
874                let j = rnd.random_range(hot_count..live.len());
875                live.swap(i, j);
876            }
877            // Delete some cold keys and add as many new keys, like tasks collected and created by
878            // a build, so the live data stays about the same size.
879            for _ in 0..rnd.random_range(0..KEY_COUNT as usize / 200) {
880                let i = rnd.random_range(hot_count..live.len());
881                commit.push((live[i], true));
882                let new_key = rnd.random::<u64>();
883                live[i] = new_key;
884                commit.push((new_key, false));
885            }
886            commit.sort_unstable();
887            written += commit.len() as u64;
888            containers.extend(split_by_shard(commit, shard_bits, false, true));
889
890            for _ in 0..small_commits {
891                rewritten += compact(&mut containers, config, shard_bits).0;
892                let mut commit = (0..20)
893                    .map(|_| (live[rnd.random_range(0..hot_count)], false))
894                    .collect::<Vec<_>>();
895                commit.sort_unstable();
896                commit.dedup();
897                written += commit.len() as u64;
898                containers.extend(split_by_shard(commit, shard_bits, false, true));
899            }
900        }
901        result.written = written;
902        result.rewritten = rewritten;
903        result
904    }
905
906    fn simulation_config(
907        max_space_amplification_percent: u16,
908        rewrite_per_fresh_byte: f32,
909    ) -> CompactConfig {
910        CompactConfig {
911            max_space_amplification_percent,
912            rewrite_per_fresh_byte,
913            max_merge_jobs: usize::MAX,
914            ..test_config()
915        }
916    }
917
918    // This 200-commit simulation didn't finish in 28 minutes under Miri. It has no unsafe code.
919    #[cfg(not(miri))]
920    #[test]
921    fn simulate_compactions() {
922        let config = simulation_config(50, 2.0);
923        let result = simulate(&config, ShardBits::new(3), 200, 0);
924        let write_amplification = result.rewritten as f64 / result.written as f64;
925        println!(
926            "space amp {:.2}, write amp {write_amplification:.2}, files per shard {}, compactions \
927             with bottom merges {}",
928            result.max_space_amplification,
929            result.max_files_per_shard,
930            result.compactions_with_bottom_merges
931        );
932        // The bottom run of a shard lags its data by at most the threshold, plus one commit and the
933        // shards waiting for quota.
934        assert!(result.max_space_amplification < 1.6);
935        assert!(result.max_files_per_shard <= config.max_files_above_bottom + 1);
936        assert!(write_amplification < 2.0);
937    }
938
939    // Like `simulate_compactions`, too slow under Miri.
940    #[cfg(not(miri))]
941    #[test]
942    fn simulate_small_commits() {
943        // Build-like commits, each followed by 10 tiny commits. Merging only the newest files of
944        // similar size doesn't rewrite the files of the build-like commits for the tiny ones.
945        let result = |size_ratio_percent| {
946            let config = CompactConfig {
947                size_ratio_percent,
948                ..simulation_config(50, 2.0)
949            };
950            let result = simulate(&config, ShardBits::new(3), 60, 10);
951            let write_amplification = result.rewritten as f64 / result.written as f64;
952            println!(
953                "size ratio {size_ratio_percent}%: write amp {write_amplification:.2}, files per \
954                 shard {}",
955                result.max_files_per_shard
956            );
957            (write_amplification, result.max_files_per_shard)
958        };
959        let (merge_all, merge_all_files) = result(u16::MAX);
960        let (similar_size, similar_size_files) = result(100);
961        assert!(
962            similar_size < merge_all * 0.8,
963            "{similar_size:.2} vs {merge_all:.2}"
964        );
965        assert!(similar_size_files <= merge_all_files + 1);
966    }
967
968    /// Prints the trade-off between space and write amplification.
969    /// Run with `cargo test -p turbo-persistence -- --ignored --nocapture sweep`.
970    #[test]
971    #[ignore]
972    fn sweep_space_amplification() {
973        println!("space amp threshold % | rewrite factor | space amp | write amp | files/shard");
974        for threshold in [25, 50, 100, 200] {
975            for factor in [1.0, 2.0, 4.0, f32::INFINITY] {
976                let result = simulate(
977                    &simulation_config(threshold, factor),
978                    ShardBits::new(3),
979                    300,
980                    0,
981                );
982                println!(
983                    "{threshold:>21} | {factor:>14} | {:>9.2} | {:>9.2} | {:>11}",
984                    result.max_space_amplification,
985                    result.rewritten as f64 / result.written as f64,
986                    result.max_files_per_shard
987                );
988            }
989        }
990    }
991}