Skip to main content

mito2/compaction/twcs/
units.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Unit-based compaction picks for one TWCS window.
16//!
17//! Picks select a contiguous interval of *units* in a fixed order. Grouping
18//! prefers rewriting related files together, but input limits and fallback
19//! paths may split groups or use single-file units. Separate admission checks
20//! preserve each pick's progress guarantees under the assumptions below.
21//!
22//! - [`pick_active_l1`] reduces the L1 file count of the active window.
23//! - [`pick_small_l1`] reduces small L1 files without rewriting large peers.
24//! - [`pick_closed_merge`] only merges intervals of an inactive window whose
25//!   outputs cannot add conflicts. Compaction outputs stay inside the bounding
26//!   box of the inputs, so they can only overlap files intruding on that box.
27//!
28//! When a window's split-unit conflict graph is too dense to materialize,
29//! [`pick_closed_merge`] falls back to a bounded file reduction and admits it
30//! only when the same conflict bound holds.
31//!
32//! # Termination
33//!
34//! Let `P` be the number of conflicting file pairs ([`Extent::conflicts`]) in an
35//! inactive window and `N` its file count. A merge removes every pair among its
36//! inputs. Its outputs are one sorted run with no internal conflicts,
37//! so they only conflict with the `I` files intruding on the inputs' box, at
38//! most `outputs · I` pairs. An inactive pick only merges inputs that had at
39//! least as many pairs with other files, so `P` does not grow; without
40//! intruders the inputs are closed and add no pairs. Every inactive pick either
41//! resolves a pair (`P` drops) or removes a file (`P` does not grow and `N`
42//! drops), so `(P, N)` strictly decreases in lexicographic order until the
43//! window receives new writes. Active-window picks may add conflicts, so each
44//! of them must remove a file: `N` strictly decreases.
45//!
46//! The argument relies on how the compactor writes outputs:
47//!
48//! - Outputs hold a subset of the input rows, so each output's time and key
49//!   bounds lie inside the inputs' bounding box.
50//! - Outputs are one stream in (key, time) order. For regions with a primary
51//!   key, the writer only splits between complete series, so output key ranges
52//!   are disjoint. Without a primary key, outputs have disjoint or touching
53//!   time ranges. Neither is an [`Extent::conflicts`] pair.
54//! - A split only starts a new file once the current one reaches the size
55//!   threshold, and outputs are not larger than inputs in total, so
56//!   [`PickLimits::predicted_outputs`] bounds the output count.
57//!
58//! `test_closed_merges_terminate` and `test_active_l1_picks_terminate` replay
59//! random windows with outputs written under these rules.
60
61use std::cmp::Reverse;
62use std::ops::Range;
63
64use bytes::Bytes;
65use common_time::Timestamp;
66
67use crate::sst::file::FileHandle;
68use crate::sst::primary_key::PrimaryKeyRangeMapper;
69
70/// An L1 file reduction may rewrite at most this many output files' worth of
71/// bytes per SST it removes.
72const MAX_REWRITE_FILES_PER_REDUCED_FILE: u64 = 4;
73
74/// A small L1 file occupies at most one quarter of the output size budget.
75const SMALL_L1_SIZE_FACTOR: u64 = 4;
76
77/// Maximum number of index nodes visited while building conflict structures for
78/// a pick. Above this, the picker falls back to a bounded file reduction instead
79/// of materializing an unbounded conflict graph.
80const CONFLICT_GRAPH_BUDGET: usize = 1 << 20;
81
82/// Output size limits and input limits shared by both picks.
83#[derive(Debug, Clone, Copy)]
84pub(crate) struct PickLimits {
85    /// Split threshold of compaction outputs; `None` writes a single output.
86    pub(crate) max_output_file_size: Option<u64>,
87    /// Maximum number of input files of one compaction.
88    pub(crate) max_input_files: usize,
89}
90
91impl PickLimits {
92    /// Upper bound of the outputs of compacting an interval: the writer only
93    /// starts a new file after the current one reaches the threshold, and never
94    /// splits a series, so files of a single primary key compact into one file.
95    fn predicted_outputs(&self, stats: &IntervalStats) -> usize {
96        match self.max_output_file_size {
97            Some(max) if max > 0 && stats.single_key.is_none() => {
98                stats.size.div_ceil(max).max(1) as usize
99            }
100            _ => 1,
101        }
102    }
103
104    /// Whether rewriting `size` bytes to remove `reduction` files stays within
105    /// the rewrite budget. An unset output size imposes no bound.
106    fn within_reduction_cost(&self, size: u64, reduction: usize) -> bool {
107        match self.max_output_file_size {
108            Some(max) if max > 0 => {
109                size <= (reduction as u64)
110                    .saturating_mul(MAX_REWRITE_FILES_PER_REDUCED_FILE)
111                    .saturating_mul(max)
112            }
113            _ => true,
114        }
115    }
116
117    /// Bytes a dominant sorted run may exceed its peers by in an overlap merge.
118    fn run_tolerance(&self) -> u64 {
119        self.max_output_file_size.unwrap_or(0)
120    }
121}
122
123/// A selected compaction input.
124#[derive(Debug)]
125pub(crate) struct UnitPick {
126    pub(crate) inputs: Vec<FileHandle>,
127    /// Whether the pick is predicted to reduce the window's file count. Otherwise
128    /// it only resolves overlaps.
129    pub(crate) reduces_files: bool,
130    /// Number of units the candidate files were grouped into.
131    pub(crate) units: usize,
132    /// Whether this pick came from the bounded dense-window fallback.
133    pub(crate) dense_fallback: bool,
134}
135
136/// Picks L1 files of the active window whose compaction removes files.
137///
138/// Units are groups of conflicting files, so a merge does not leave a file
139/// behind while rewriting its overlapping peers. A group larger than the input
140/// limit is split back into single files. If no interval of groups qualifies,
141/// for example because a large file dominates its group, the groups are split
142/// into single files, so the smaller files can still be merged. Intervals
143/// follow [`sort_units`]; the pick removes the most files, then rewrites the
144/// fewest bytes.
145pub(crate) fn pick_active_l1(
146    l1_files: &[FileHandle],
147    mapper: &PrimaryKeyRangeMapper,
148    options: PickLimits,
149) -> Option<UnitPick> {
150    pick_active_l1_with_budget(l1_files, mapper, options, CONFLICT_GRAPH_BUDGET)
151}
152
153/// [`pick_active_l1`] with an explicit work budget, so tests can exercise the
154/// single-file fallback without a window-sized conflict graph.
155fn pick_active_l1_with_budget(
156    l1_files: &[FileHandle],
157    mapper: &PrimaryKeyRangeMapper,
158    options: PickLimits,
159    work_budget: usize,
160) -> Option<UnitPick> {
161    let extents = extents_of(l1_files, mapper);
162    let index = ExtentIndex::new(&extents);
163    let mut units = match conflict_units(l1_files, &extents, &index, work_budget) {
164        Some(units) => units,
165        None => single_file_units(l1_files, &extents),
166    };
167    split_oversized_units(&mut units, l1_files, &extents, options.max_input_files);
168    if let Some(pick) = pick_reducing_interval(&mut units, l1_files, options) {
169        return Some(pick);
170    }
171    if units.len() == l1_files.len() {
172        return None;
173    }
174    split_oversized_units(&mut units, l1_files, &extents, 1);
175    pick_reducing_interval(&mut units, l1_files, options)
176}
177
178/// Picks the interval of `units` that removes the most files within the
179/// rewrite budget, then rewrites the fewest bytes.
180fn pick_reducing_interval(
181    units: &mut [Unit],
182    files: &[FileHandle],
183    options: PickLimits,
184) -> Option<UnitPick> {
185    sort_units(units);
186    let range = pick_reducing_interval_where(units, options, |_| true)?;
187    Some(UnitPick {
188        inputs: collect_inputs(&units[range], files),
189        reduces_files: true,
190        units: units.len(),
191        dense_fallback: false,
192    })
193}
194
195/// Selects a reducing interval of sorted units, with an additional filter so
196/// the dense fallback can retry without duplicating the reduction guards.
197fn pick_reducing_interval_where(
198    units: &[Unit],
199    options: PickLimits,
200    is_eligible: impl Fn(&IntervalStats) -> bool,
201) -> Option<Range<usize>> {
202    let mut best: Option<(usize, u64, Range<usize>)> = None;
203    // File reductions ignore conflicts, so split files are not counted.
204    for_each_interval(units, &[], options.max_input_files, |range, stats| {
205        let reduction = stats
206            .num_files
207            .saturating_sub(options.predicted_outputs(stats));
208        if stats.num_files < 2
209            || reduction == 0
210            || !options.within_reduction_cost(stats.size, reduction)
211            || stats.largest_file_size > stats.size - stats.largest_file_size
212            || !is_eligible(stats)
213        {
214            return;
215        }
216        let better = best.as_ref().is_none_or(|(best_reduction, best_size, _)| {
217            (reduction, Reverse(stats.size)) > (*best_reduction, Reverse(*best_size))
218        });
219        if better {
220            best = Some((reduction, stats.size, range));
221        }
222    });
223
224    best.map(|(_, _, range)| range)
225}
226
227/// Reduces small L1 files left behind by a closed merge, including in settling
228/// windows. The input bytes fit in one output and no input dominates its peers,
229/// so each merge removes files without repeatedly rewriting an accumulator.
230///
231/// Large and L0 files remain in the external conflict index. Check only the
232/// best reducing interval to bound planning work; rejecting it can miss other
233/// admissible intervals, but cannot increase the window's conflict count.
234pub(crate) fn pick_small_l1(
235    files: &[FileHandle],
236    mapper: &PrimaryKeyRangeMapper,
237    limits: PickLimits,
238) -> Option<UnitPick> {
239    let max_output = limits.max_output_file_size.filter(|size| *size > 0)?;
240    let extents = extents_of(files, mapper);
241    let mut units = single_file_units(files, &extents);
242    units.retain(|unit| {
243        let file = &files[unit.files[0]];
244        file.level() == 1 && file.size() <= max_output / SMALL_L1_SIZE_FACTOR
245    });
246    sort_units(&mut units);
247    let range = pick_reducing_interval_where(&units, limits, |stats| stats.size <= max_output)?;
248    let index = ExtentIndex::new(&extents);
249    if !adds_no_conflicts(&units[range.clone()], &extents, &index, 1) {
250        return None;
251    }
252    Some(UnitPick {
253        inputs: collect_inputs(&units[range], files),
254        reduces_files: true,
255        units: units.len(),
256        dense_fallback: false,
257    })
258}
259
260/// Thresholds of the closed merge pick.
261#[derive(Debug, Clone, Copy)]
262pub(crate) struct ClosedMergeOptions {
263    pub(crate) limits: PickLimits,
264    /// Whether merges that only remove files are allowed.
265    pub(crate) allow_file_reduction: bool,
266    /// Whether merges may rewrite conflicting files. Otherwise only
267    /// conflict-free intervals are merged.
268    pub(crate) allow_conflicts: bool,
269    /// Whether overlap merges may rewrite a dominant sorted run for a small
270    /// peer. Otherwise such a merge would be repeated whenever late data adds
271    /// another small peer.
272    pub(crate) allow_unbalanced: bool,
273}
274
275/// Picks an interval of units among all files of an inactive window.
276///
277/// Units are the closures of conflicting files: no file outside a unit intrudes
278/// on its bounding box. A closure larger than the input limit is split back into
279/// single files, so files around a late file spanning a large sorted run can
280/// still be merged. Intervals follow [`sort_units`]. An interval is eligible if
281/// its outputs add no more conflicts than its files have with other files (see
282/// [`adds_no_conflicts`]), and it
283///
284/// - removes files within the rewrite budget, or
285/// - resolves conflicts without rewriting a dominant sorted run (unless
286///   [`ClosedMergeOptions::allow_unbalanced`]), and adds at most one tail file.
287///
288/// The pick maximizes resolved conflicts plus removed files per rewritten byte.
289/// A window whose split-unit conflict graph is too dense to materialize falls
290/// back to a bounded file reduction; see [`pick_dense_closed_merge`].
291pub(crate) fn pick_closed_merge(
292    files: &[FileHandle],
293    mapper: &PrimaryKeyRangeMapper,
294    options: ClosedMergeOptions,
295) -> Option<UnitPick> {
296    pick_closed_merge_with_budget(files, mapper, options, CONFLICT_GRAPH_BUDGET)
297}
298
299/// [`pick_closed_merge`] with an explicit work budget, so tests can exercise
300/// the dense fallback without building a window-sized conflict graph.
301fn pick_closed_merge_with_budget(
302    files: &[FileHandle],
303    mapper: &PrimaryKeyRangeMapper,
304    options: ClosedMergeOptions,
305    work_budget: usize,
306) -> Option<UnitPick> {
307    let [best, _] = pick_closed_merge_candidates(files, mapper, options, work_budget, false);
308    best
309}
310
311/// Discovers file reduction and overlap resolution together, sharing both the
312/// conflict graph and interval traversal. The first slot is file reduction.
313pub(crate) fn pick_closed_merge_by_class(
314    files: &[FileHandle],
315    mapper: &PrimaryKeyRangeMapper,
316    options: ClosedMergeOptions,
317) -> [Option<UnitPick>; 2] {
318    pick_closed_merge_candidates(files, mapper, options, CONFLICT_GRAPH_BUDGET, true)
319}
320
321/// A scored interval retained until the best candidate(s) are materialized.
322struct ClosedMergeCandidate {
323    benefit: u64,
324    size: u64,
325    range: Range<usize>,
326    reduces_files: bool,
327}
328
329fn pick_closed_merge_candidates(
330    files: &[FileHandle],
331    mapper: &PrimaryKeyRangeMapper,
332    options: ClosedMergeOptions,
333    work_budget: usize,
334    per_class: bool,
335) -> [Option<UnitPick>; 2] {
336    let limits = options.limits;
337    let extents = extents_of(files, mapper);
338    let index = ExtentIndex::new(&extents);
339    let mut dense = false;
340    let mut units = match conflict_units(files, &extents, &index, work_budget) {
341        Some(mut units) => {
342            close_units(&mut units, &extents, &index);
343            split_oversized_units(&mut units, files, &extents, limits.max_input_files);
344            units
345        }
346        None => {
347            dense = true;
348            single_file_units(files, &extents)
349        }
350    };
351    sort_units(&mut units);
352    if dense {
353        return [
354            pick_dense_closed_merge(&units, files, &extents, &index, options),
355            None,
356        ];
357    }
358    let Some(pairs) = split_conflicts(&units, &extents, &index, work_budget) else {
359        return [
360            pick_dense_closed_merge(&units, files, &extents, &index, options),
361            None,
362        ];
363    };
364
365    let mut best: [Option<ClosedMergeCandidate>; 2] = [None, None];
366    for_each_interval(&units, &pairs, limits.max_input_files, |range, stats| {
367        if stats.num_files < 2 {
368            return;
369        }
370        let outputs = limits.predicted_outputs(stats);
371        if outputs > stats.num_files + 1 {
372            return;
373        }
374        let reduction = stats.num_files.saturating_sub(outputs);
375        let reduces_files = reduction > 0
376            && (options.allow_file_reduction || stats.conflicts > 0)
377            && limits.within_reduction_cost(stats.size, reduction);
378        if (stats.conflicts > 0 && !options.allow_conflicts)
379            || (!reduces_files && stats.conflicts == 0)
380        {
381            return;
382        }
383        // Stateless/manual picks compare all intervals in one slot, retaining
384        // their original efficiency ordering and first-interval tie breaker.
385        let best = &mut best[usize::from(per_class && !reduces_files)];
386        let benefit = (reduction + stats.conflicts) as u64;
387        let better = best.as_ref().is_none_or(|candidate| {
388            is_more_efficient(benefit, stats.size, candidate.benefit, candidate.size)
389        });
390        if !better {
391            return;
392        }
393        let units = &units[range.clone()];
394        // Overlap merges must not rewrite a dominant run for a small peer. A
395        // file-reducing merge is already bounded by its rewrite budget.
396        if !reduces_files
397            && !options.allow_unbalanced
398            && !has_balanced_components(units, files, &extents, limits.run_tolerance())
399        {
400            return;
401        }
402        if units.len() > 1 && !adds_no_conflicts(units, &extents, &index, outputs) {
403            return;
404        }
405        *best = Some(ClosedMergeCandidate {
406            benefit,
407            size: stats.size,
408            range,
409            reduces_files,
410        });
411    });
412
413    best.map(|candidate| {
414        candidate.map(|candidate| UnitPick {
415            inputs: collect_inputs(&units[candidate.range], files),
416            reduces_files: candidate.reduces_files,
417            units: units.len(),
418            dense_fallback: false,
419        })
420    })
421}
422
423/// Picks a bounded file reduction when the split-unit conflict graph is too
424/// dense to materialize.
425///
426/// The candidate comes from [`pick_reducing_interval_where`], which ignores
427/// conflicts. If rejected, it is searched once more for a one-output interval.
428/// Both candidates must pass [`adds_no_conflicts`], so the `(P, N)` termination
429/// argument still holds. Searching only within the original candidate adds at
430/// most O(K²) interval visits for K input files and one extra conflict check,
431/// rather than checking external conflicts for every interval in the window.
432/// This bounded retry can still miss other safely reducible intervals.
433fn pick_dense_closed_merge(
434    units: &[Unit],
435    files: &[FileHandle],
436    extents: &[Extent],
437    index: &ExtentIndex,
438    options: ClosedMergeOptions,
439) -> Option<UnitPick> {
440    let limits = options.limits;
441    let range = pick_reducing_interval_where(units, limits, |_| true)?;
442    let mut selected = &units[range];
443    if !is_admissible_dense_interval(selected, extents, index, options) {
444        let range = pick_reducing_interval_where(selected, limits, |stats| {
445            limits.predicted_outputs(stats) == 1
446        })?;
447        selected = &selected[range];
448        if !is_admissible_dense_interval(selected, extents, index, options) {
449            return None;
450        }
451    }
452    Some(UnitPick {
453        inputs: collect_inputs(selected, files),
454        reduces_files: true,
455        units: units.len(),
456        dense_fallback: true,
457    })
458}
459
460/// File reduction and rewrite limits are checked by the interval picker;
461/// each dense candidate must also respect the closed-window conflict policy.
462fn is_admissible_dense_interval(
463    units: &[Unit],
464    extents: &[Extent],
465    index: &ExtentIndex,
466    options: ClosedMergeOptions,
467) -> bool {
468    let stats = interval_stats_exact(units, extents);
469    (options.allow_file_reduction || stats.conflicts > 0)
470        && (options.allow_conflicts || stats.conflicts == 0)
471        && adds_no_conflicts(
472            units,
473            extents,
474            index,
475            options.limits.predicted_outputs(&stats),
476        )
477}
478
479/// Totals of an interval without materializing [`split_conflicts`].
480///
481/// After [`close_units`], files of different units never conflict, so only
482/// split units can conflict across units, and each split unit holds one file.
483/// The dense fallback may instead treat every file as a split unit, and then
484/// the same pairwise loop covers every cross-unit pair. Counting them takes
485/// `O(K²)` for an interval of at most `K` files, instead of building a
486/// window-wide adjacency.
487fn interval_stats_exact<'a>(units: &'a [Unit], extents: &[Extent]) -> IntervalStats<'a> {
488    let mut stats = IntervalStats::default();
489    let mut first = true;
490    for unit in units {
491        stats.num_files += unit.files.len();
492        stats.size += unit.size;
493        stats.largest_file_size = stats.largest_file_size.max(unit.largest_file_size);
494        stats.conflicts += unit.conflicts;
495        stats.single_key = if first {
496            unit.single_key()
497        } else {
498            stats
499                .single_key
500                .filter(|&key| unit.single_key() == Some(key))
501        };
502        first = false;
503    }
504
505    let split_files: Vec<usize> = units
506        .iter()
507        .filter(|unit| unit.split)
508        .map(|unit| unit.files[0])
509        .collect();
510    for (offset, &lhs) in split_files.iter().enumerate() {
511        for &rhs in &split_files[offset + 1..] {
512            if extents[lhs].conflicts(&extents[rhs]) {
513                stats.conflicts += 1;
514            }
515        }
516    }
517    stats
518}
519
520/// Orders units by minimum primary key, then by time, so adjacent units are
521/// key-adjacent. Compaction outputs and time-sliced flushes split a window by
522/// key ranges, so merging neighbors in this order keeps outputs from covering
523/// the key ranges of files left behind.
524fn sort_units(units: &mut [Unit]) {
525    units.sort_by(|lhs, rhs| {
526        (lhs.first_key(), lhs.extent.start, Reverse(lhs.extent.end))
527            .cmp(&(rhs.first_key(), rhs.extent.start, Reverse(rhs.extent.end)))
528            .then_with(|| lhs.files[0].cmp(&rhs.files[0]))
529    });
530}
531
532/// Whether `benefit` per `size` bytes beats the best so far; ties prefer fewer
533/// bytes, then the earlier interval.
534fn is_more_efficient(benefit: u64, size: u64, best_benefit: u64, best_size: u64) -> bool {
535    let lhs = u128::from(benefit) * u128::from(best_size.max(1));
536    let rhs = u128::from(best_benefit) * u128::from(size.max(1));
537    lhs > rhs || (lhs == rhs && size < best_size)
538}
539
540/// Time and primary key bounds of a file or a group of files.
541#[derive(Debug, Clone)]
542pub(crate) struct Extent {
543    start: Timestamp,
544    end: Timestamp,
545    /// Inclusive primary key bounds. `None` is unknown and matches any key.
546    primary_key: Option<(Bytes, Bytes)>,
547}
548
549impl Extent {
550    pub(crate) fn of_file(file: &FileHandle, mapper: &PrimaryKeyRangeMapper) -> Self {
551        let (start, end) = file.time_range();
552        Self {
553            start,
554            end,
555            primary_key: file.primary_key_range(mapper),
556        }
557    }
558
559    fn union(&self, other: &Self) -> Self {
560        Self {
561            start: self.start.min(other.start),
562            end: self.end.max(other.end),
563            primary_key: match (&self.primary_key, &other.primary_key) {
564                (Some((lhs_min, lhs_max)), Some((rhs_min, rhs_max))) => Some((
565                    lhs_min.clone().min(rhs_min.clone()),
566                    lhs_max.clone().max(rhs_max.clone()),
567                )),
568                _ => None,
569            },
570        }
571    }
572
573    /// Time ranges overlap; touching boundaries do not.
574    fn time_overlaps(&self, other: &Self) -> bool {
575        self.start.max(other.start) < self.end.min(other.end)
576    }
577
578    /// Whether two files overlap for the picker.
579    ///
580    /// Shared boundary keys count: independently written files can hold
581    /// different versions of the same row there. Compaction outputs do not
582    /// conflict with each other because the writer never splits a series.
583    pub(crate) fn conflicts(&self, other: &Self) -> bool {
584        self.time_overlaps(other)
585            && match (&self.primary_key, &other.primary_key) {
586                (Some(lhs), Some(rhs)) => {
587                    let (low, high) = ((&lhs.0).max(&rhs.0), (&lhs.1).min(&rhs.1));
588                    low <= high
589                }
590                _ => true,
591            }
592    }
593
594    /// Whether `self` may conflict with a file inside `area`, which is the
595    /// bounding box of a group of files.
596    ///
597    /// A shared boundary key may occur in an output, so the same inclusive
598    /// key overlap used for individual files also applies to the box.
599    fn intrudes(&self, area: &Self) -> bool {
600        self.conflicts(area)
601    }
602}
603
604fn is_single_key(range: &(Bytes, Bytes)) -> bool {
605    range.0 == range.1
606}
607
608/// Upper primary key bound, ordered after every key when unknown.
609#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
610enum UpperKey<'a> {
611    Key(&'a Bytes),
612    Unbounded,
613}
614
615/// Key and time bounds of the files in a subtree of [`ExtentIndex`].
616#[derive(Debug, Clone, Copy)]
617struct SubtreeBounds<'a> {
618    max_upper_key: UpperKey<'a>,
619    min_start: Timestamp,
620    max_end: Timestamp,
621}
622
623impl<'a> SubtreeBounds<'a> {
624    fn of(extent: &'a Extent) -> Self {
625        Self {
626            max_upper_key: match &extent.primary_key {
627                Some((_, max)) => UpperKey::Key(max),
628                None => UpperKey::Unbounded,
629            },
630            min_start: extent.start,
631            max_end: extent.end,
632        }
633    }
634
635    fn merge(self, other: Self) -> Self {
636        Self {
637            max_upper_key: self.max_upper_key.max(other.max_upper_key),
638            min_start: self.min_start.min(other.min_start),
639            max_end: self.max_end.max(other.max_end),
640        }
641    }
642}
643
644/// Files of a window ordered by their lower primary key, in a segment tree
645/// holding the bounds of each subtree.
646///
647/// A query visits the files whose key and time ranges may overlap an area and
648/// skips subtrees whose keys end before the area or whose times miss it. The
649/// files of a sorted run split by key then only meet their key neighbors,
650/// instead of every file sharing their time range.
651struct ExtentIndex<'a> {
652    extents: &'a [Extent],
653    /// Indices into `extents`, sorted by lower key with unknown keys first.
654    /// Sorted offset `i` is leaf `leaf_base + i`.
655    by_lower_key: Vec<usize>,
656    /// Array-backed tree rooted at index 1; padding leaves are `None`.
657    bounds: Vec<Option<SubtreeBounds<'a>>>,
658    /// First leaf index, a power of two.
659    leaf_base: usize,
660}
661
662impl<'a> ExtentIndex<'a> {
663    fn new(extents: &'a [Extent]) -> Self {
664        let lower_key = |i: usize| extents[i].primary_key.as_ref().map(|(min, _)| min);
665        let mut by_lower_key: Vec<usize> = (0..extents.len()).collect();
666        by_lower_key.sort_by(|&lhs, &rhs| {
667            (lower_key(lhs), extents[lhs].start).cmp(&(lower_key(rhs), extents[rhs].start))
668        });
669        let leaf_base = extents.len().next_power_of_two();
670        let mut bounds = vec![None; 2 * leaf_base];
671        for (offset, &i) in by_lower_key.iter().enumerate() {
672            bounds[leaf_base + offset] = Some(SubtreeBounds::of(&extents[i]));
673        }
674        for node in (1..leaf_base).rev() {
675            bounds[node] = match (bounds[2 * node], bounds[2 * node + 1]) {
676                (Some(lhs), Some(rhs)) => Some(lhs.merge(rhs)),
677                (lhs, rhs) => lhs.or(rhs),
678            };
679        }
680        Self {
681            extents,
682            by_lower_key,
683            bounds,
684            leaf_base,
685        }
686    }
687
688    /// Visits the files whose inclusive key range meets `area`'s and whose time
689    /// range overlaps it, a superset of the files that conflict with or intrude
690    /// on `area`. Takes O(log N) per visited file.
691    fn for_each_near(&self, area: &Extent, visit: impl FnMut(usize)) {
692        let mut budget = usize::MAX;
693        let _ = self.for_each_near_with_budget(area, &mut budget, visit);
694    }
695
696    /// [`for_each_near`] with a budget on visited tree nodes.
697    ///
698    /// Every node reached by the traversal consumes one unit, including nodes
699    /// pruned before any file callback. Returns `false` as soon as the budget is
700    /// exhausted, so a caller can stop a traversal whose pruning is poor even
701    /// when it finds few files.
702    fn for_each_near_with_budget(
703        &self,
704        area: &Extent,
705        budget: &mut usize,
706        mut visit: impl FnMut(usize),
707    ) -> bool {
708        let leaves_end = match &area.primary_key {
709            Some((_, max)) => self.by_lower_key.partition_point(|&i| {
710                self.extents[i]
711                    .primary_key
712                    .as_ref()
713                    .is_none_or(|(min, _)| min <= max)
714            }),
715            None => self.by_lower_key.len(),
716        };
717        self.visit_subtree(1, 0..self.leaf_base, leaves_end, area, budget, &mut visit)
718    }
719
720    fn visit_subtree(
721        &self,
722        node: usize,
723        leaves: Range<usize>,
724        leaves_end: usize,
725        area: &Extent,
726        budget: &mut usize,
727        visit: &mut impl FnMut(usize),
728    ) -> bool {
729        if *budget == 0 {
730            return false;
731        }
732        *budget -= 1;
733        if leaves.start >= leaves_end {
734            return true;
735        }
736        let Some(bounds) = self.bounds[node] else {
737            return true;
738        };
739        if bounds.min_start >= area.end
740            || bounds.max_end <= area.start
741            || area
742                .primary_key
743                .as_ref()
744                .is_some_and(|(min, _)| bounds.max_upper_key < UpperKey::Key(min))
745        {
746            return true;
747        }
748        if leaves.len() == 1 {
749            let i = self.by_lower_key[leaves.start];
750            if self.extents[i].time_overlaps(area) {
751                visit(i);
752            }
753            return true;
754        }
755        let mid = leaves.start + leaves.len() / 2;
756        if !self.visit_subtree(2 * node, leaves.start..mid, leaves_end, area, budget, visit) {
757            return false;
758        }
759        self.visit_subtree(
760            2 * node + 1,
761            mid..leaves.end,
762            leaves_end,
763            area,
764            budget,
765            visit,
766        )
767    }
768}
769
770fn extents_of(files: &[FileHandle], mapper: &PrimaryKeyRangeMapper) -> Vec<Extent> {
771    files
772        .iter()
773        .map(|file| Extent::of_file(file, mapper))
774        .collect()
775}
776
777/// Files that are compacted together.
778#[derive(Debug)]
779struct Unit {
780    /// Ascending indices into the candidate files.
781    files: Vec<usize>,
782    extent: Extent,
783    size: u64,
784    largest_file_size: u64,
785    /// Number of conflicting file pairs inside the unit.
786    conflicts: usize,
787    /// A single file split from a unit over the input limit. It may conflict
788    /// with other split files, see [`split_conflicts`].
789    split: bool,
790}
791
792impl Unit {
793    fn new(
794        members: Vec<usize>,
795        files: &[FileHandle],
796        extents: &[Extent],
797        conflicts: usize,
798    ) -> Self {
799        let extent = members[1..]
800            .iter()
801            .fold(extents[members[0]].clone(), |extent, &i| {
802                extent.union(&extents[i])
803            });
804        let sizes = members.iter().map(|&i| files[i].size());
805        Self {
806            size: sizes.clone().sum(),
807            largest_file_size: sizes.max().unwrap_or(0),
808            files: members,
809            extent,
810            conflicts,
811            split: false,
812        }
813    }
814
815    /// The minimum primary key, with unknown keys ordered first.
816    fn first_key(&self) -> Option<&Bytes> {
817        self.extent.primary_key.as_ref().map(|(min, _)| min)
818    }
819
820    /// The primary key of a unit whose files all hold that single key.
821    fn single_key(&self) -> Option<&Bytes> {
822        self.extent
823            .primary_key
824            .as_ref()
825            .filter(|range| is_single_key(range))
826            .map(|(min, _)| min)
827    }
828}
829
830/// Groups files into connected components of [`Extent::conflicts`], each with
831/// its number of conflicting pairs. Each file only checks the files the index
832/// finds near it: O((N + M) log N) for M pairs of files whose key and time
833/// ranges meet.
834///
835/// Returns `None` once the index traversal has visited more than `work_budget`
836/// nodes, so a dense window can fall back before building a quadratic graph.
837fn conflict_units(
838    files: &[FileHandle],
839    extents: &[Extent],
840    index: &ExtentIndex,
841    work_budget: usize,
842) -> Option<Vec<Unit>> {
843    let mut sets = DisjointSet::new(extents.len());
844    let mut conflicts = vec![0; extents.len()];
845    let mut budget = work_budget;
846    for i in 0..extents.len() {
847        let completed = index.for_each_near_with_budget(&extents[i], &mut budget, |j| {
848            // Counts each pair once, from its smaller index.
849            if j > i && extents[i].conflicts(&extents[j]) {
850                sets.union(i, j);
851                conflicts[i] += 1;
852            }
853        });
854        if !completed {
855            return None;
856        }
857    }
858    Some(
859        sets.groups()
860            .into_iter()
861            .map(|members| {
862                let pairs = members.iter().map(|&i| conflicts[i]).sum();
863                Unit::new(members, files, extents, pairs)
864            })
865            .collect(),
866    )
867}
868
869/// Treats every file as its own split unit, for the dense fallback that cannot
870/// afford to build conflict components.
871fn single_file_units(files: &[FileHandle], extents: &[Extent]) -> Vec<Unit> {
872    (0..files.len())
873        .map(|i| Unit {
874            split: true,
875            ..Unit::new(vec![i], files, extents, 0)
876        })
877        .collect()
878}
879
880/// Replaces units with more files than one compaction accepts by single files,
881/// which are no longer closed.
882fn split_oversized_units(
883    units: &mut Vec<Unit>,
884    files: &[FileHandle],
885    extents: &[Extent],
886    max_input_files: usize,
887) {
888    let (oversized, mut kept): (Vec<_>, Vec<_>) = std::mem::take(units)
889        .into_iter()
890        .partition(|unit| unit.files.len() > max_input_files);
891    kept.extend(
892        oversized
893            .into_iter()
894            .flat_map(|unit| unit.files)
895            .map(|i| Unit {
896                split: true,
897                ..Unit::new(vec![i], files, extents, 0)
898            }),
899    );
900    *units = kept;
901}
902
903/// Merges units until no file intrudes on a unit it does not belong to.
904///
905/// Each check only visits the files the index finds near the unit, and each
906/// merge re-checks only the merged unit.
907fn close_units(units: &mut Vec<Unit>, extents: &[Extent], index: &ExtentIndex) {
908    let mut owner = vec![0; extents.len()];
909    for (u, unit) in units.iter().enumerate() {
910        for &i in &unit.files {
911            owner[i] = u;
912        }
913    }
914    let mut alive = vec![true; units.len()];
915    let mut pending: Vec<usize> = (0..units.len()).collect();
916    while let Some(u) = pending.pop() {
917        if !alive[u] {
918            continue;
919        }
920        let area = &units[u].extent;
921        let mut intruders = std::collections::BTreeSet::new();
922        index.for_each_near(area, |i| {
923            if owner[i] != u && extents[i].intrudes(area) {
924                intruders.insert(owner[i]);
925            }
926        });
927        if intruders.is_empty() {
928            continue;
929        }
930        for v in intruders {
931            alive[v] = false;
932            let absorbed = std::mem::take(&mut units[v].files);
933            for &i in &absorbed {
934                owner[i] = u;
935            }
936            let (extent, size, largest, conflicts) = {
937                let other = &units[v];
938                (
939                    other.extent.clone(),
940                    other.size,
941                    other.largest_file_size,
942                    other.conflicts,
943                )
944            };
945            let unit = &mut units[u];
946            unit.files.extend(absorbed);
947            unit.extent = unit.extent.union(&extent);
948            unit.size += size;
949            unit.largest_file_size = unit.largest_file_size.max(largest);
950            unit.conflicts += conflicts;
951        }
952        pending.push(u);
953    }
954    let mut closed = Vec::with_capacity(units.len());
955    for (u, mut unit) in std::mem::take(units).into_iter().enumerate() {
956        if alive[u] {
957            unit.files.sort_unstable();
958            closed.push(unit);
959        }
960    }
961    *units = closed;
962}
963
964/// Totals of a contiguous interval of units.
965#[derive(Debug, Default)]
966struct IntervalStats<'a> {
967    num_files: usize,
968    size: u64,
969    largest_file_size: u64,
970    conflicts: usize,
971    /// The primary key of an interval whose files all hold that single key.
972    single_key: Option<&'a Bytes>,
973}
974
975/// Positions of the split units each unit conflicts with, sorted. Files of
976/// different units never conflict, so only split files have any.
977///
978/// Returns `None` once the index traversal has visited more than `work_budget`
979/// nodes, so a dense window cannot materialize an unbounded adjacency.
980fn split_conflicts(
981    units: &[Unit],
982    extents: &[Extent],
983    index: &ExtentIndex,
984    work_budget: usize,
985) -> Option<Vec<Vec<usize>>> {
986    let mut position = vec![None; extents.len()];
987    for (p, unit) in units.iter().enumerate() {
988        if unit.split {
989            position[unit.files[0]] = Some(p);
990        }
991    }
992    let mut budget = work_budget;
993    let mut result = Vec::with_capacity(units.len());
994    for unit in units {
995        if !unit.split {
996            result.push(Vec::new());
997            continue;
998        }
999        let i = unit.files[0];
1000        let mut neighbors = Vec::new();
1001        let completed = index.for_each_near_with_budget(&extents[i], &mut budget, |j| {
1002            if let Some(p) = position[j]
1003                && j != i
1004                && extents[i].conflicts(&extents[j])
1005            {
1006                neighbors.push(p);
1007            }
1008        });
1009        if !completed {
1010            return None;
1011        }
1012        neighbors.sort_unstable();
1013        result.push(neighbors);
1014    }
1015    Some(result)
1016}
1017
1018/// Visits every contiguous interval of units with at most `max_input_files`
1019/// files: O(U · K) intervals for an input limit K. Each interval counts the
1020/// conflicts of its split files from `split_conflicts` (see
1021/// [`split_conflicts`]) with a binary search; an empty `split_conflicts`
1022/// skips them.
1023fn for_each_interval<'a>(
1024    units: &'a [Unit],
1025    split_conflicts: &[Vec<usize>],
1026    max_input_files: usize,
1027    mut visit: impl FnMut(Range<usize>, &IntervalStats<'a>),
1028) {
1029    for left in 0..units.len() {
1030        let mut stats = IntervalStats::default();
1031        for (right, unit) in units.iter().enumerate().skip(left) {
1032            stats.num_files += unit.files.len();
1033            if stats.num_files > max_input_files {
1034                break;
1035            }
1036            stats.size += unit.size;
1037            stats.largest_file_size = stats.largest_file_size.max(unit.largest_file_size);
1038            stats.conflicts += unit.conflicts;
1039            stats.single_key = if right == left {
1040                unit.single_key()
1041            } else {
1042                stats
1043                    .single_key
1044                    .filter(|&key| unit.single_key() == Some(key))
1045            };
1046            if let Some(neighbors) = split_conflicts.get(right) {
1047                // Conflicts with the split files already in the interval.
1048                stats.conflicts += neighbors.partition_point(|&p| p < right)
1049                    - neighbors.partition_point(|&p| p < left);
1050            }
1051            visit(left..right + 1, &stats);
1052        }
1053    }
1054}
1055
1056/// Whether merging `units` into `outputs` files adds no more conflicts than
1057/// their files have with other files. Outputs stay inside the bounding box of
1058/// the inputs, so each of them may conflict with every file intruding on that
1059/// box. An interval without intruders is closed and adds no conflicts.
1060fn adds_no_conflicts(
1061    units: &[Unit],
1062    extents: &[Extent],
1063    index: &ExtentIndex,
1064    outputs: usize,
1065) -> bool {
1066    let area = units[1..]
1067        .iter()
1068        .fold(units[0].extent.clone(), |area, unit| {
1069            area.union(&unit.extent)
1070        });
1071    let selected: std::collections::HashSet<usize> = units
1072        .iter()
1073        .flat_map(|unit| unit.files.iter().copied())
1074        .collect();
1075    let mut intruders = Vec::new();
1076    index.for_each_near(&area, |i| {
1077        if !selected.contains(&i) && extents[i].intrudes(&area) {
1078            intruders.push(i);
1079        }
1080    });
1081    if intruders.is_empty() {
1082        return true;
1083    }
1084    let existing: usize = intruders
1085        .iter()
1086        .map(|&other| {
1087            units
1088                .iter()
1089                .flat_map(|unit| unit.files.iter())
1090                .filter(|&&i| extents[i].conflicts(&extents[other]))
1091                .count()
1092        })
1093        .sum();
1094    outputs.saturating_mul(intruders.len()) <= existing
1095}
1096
1097/// Whether every group of conflicting files among `units` is balanced, see
1098/// [`has_balanced_runs`]. Runs of separate groups never overlap, so pairing a
1099/// dominant run with the peer of another group would hide it. Takes O(K²)
1100/// conflict checks for K files.
1101fn has_balanced_components(
1102    units: &[Unit],
1103    files: &[FileHandle],
1104    extents: &[Extent],
1105    tolerance: u64,
1106) -> bool {
1107    let members: Vec<usize> = units
1108        .iter()
1109        .flat_map(|unit| unit.files.iter().copied())
1110        .collect();
1111    let mut sets = DisjointSet::new(members.len());
1112    for (i, &lhs) in members.iter().enumerate() {
1113        for (j, &rhs) in members.iter().enumerate().skip(i + 1) {
1114            if extents[lhs].conflicts(&extents[rhs]) {
1115                sets.union(i, j);
1116            }
1117        }
1118    }
1119    sets.groups()
1120        .into_iter()
1121        .filter(|group| group.len() > 1)
1122        .all(|group| {
1123            has_balanced_runs(
1124                group.into_iter().map(|i| members[i]),
1125                files,
1126                extents,
1127                tolerance,
1128            )
1129        })
1130}
1131
1132/// Whether the largest sorted run among `files` holds at most `tolerance` bytes
1133/// more than all other runs together. Runs are built greedily in time order,
1134/// which takes O(K²) conflict checks for K files.
1135fn has_balanced_runs(
1136    members: impl Iterator<Item = usize>,
1137    files: &[FileHandle],
1138    extents: &[Extent],
1139    tolerance: u64,
1140) -> bool {
1141    let mut members: Vec<usize> = members.collect();
1142    members.sort_by(|&lhs, &rhs| {
1143        (extents[lhs].start, Reverse(extents[lhs].end), lhs).cmp(&(
1144            extents[rhs].start,
1145            Reverse(extents[rhs].end),
1146            rhs,
1147        ))
1148    });
1149    let mut runs: Vec<(Vec<usize>, u64)> = Vec::new();
1150    for i in members {
1151        let size = files[i].size();
1152        match runs.iter_mut().find(|(run, _)| {
1153            run.iter()
1154                .all(|&other| !extents[i].conflicts(&extents[other]))
1155        }) {
1156            Some((run, run_size)) => {
1157                run.push(i);
1158                *run_size += size;
1159            }
1160            None => runs.push((vec![i], size)),
1161        }
1162    }
1163    let total: u64 = runs.iter().map(|(_, size)| size).sum();
1164    let largest = runs.iter().map(|(_, size)| *size).max().unwrap_or(0);
1165    largest <= (total - largest).saturating_add(tolerance)
1166}
1167
1168fn collect_inputs(units: &[Unit], files: &[FileHandle]) -> Vec<FileHandle> {
1169    let mut indices: Vec<usize> = units
1170        .iter()
1171        .flat_map(|unit| unit.files.iter().copied())
1172        .collect();
1173    indices.sort_unstable();
1174    indices.into_iter().map(|i| files[i].clone()).collect()
1175}
1176
1177/// Union-find over file indices; each root is the smallest index of its set.
1178struct DisjointSet {
1179    parent: Vec<usize>,
1180}
1181
1182impl DisjointSet {
1183    fn new(len: usize) -> Self {
1184        Self {
1185            parent: (0..len).collect(),
1186        }
1187    }
1188
1189    fn find(&mut self, mut i: usize) -> usize {
1190        while self.parent[i] != i {
1191            self.parent[i] = self.parent[self.parent[i]];
1192            i = self.parent[i];
1193        }
1194        i
1195    }
1196
1197    fn union(&mut self, lhs: usize, rhs: usize) {
1198        let (lhs, rhs) = (self.find(lhs), self.find(rhs));
1199        let (root, child) = (lhs.min(rhs), lhs.max(rhs));
1200        self.parent[child] = root;
1201    }
1202
1203    /// Sets in ascending order of their smallest index, each sorted.
1204    fn groups(&mut self) -> Vec<Vec<usize>> {
1205        let mut groups: Vec<Vec<usize>> = Vec::new();
1206        let mut slot = vec![usize::MAX; self.parent.len()];
1207        for i in 0..self.parent.len() {
1208            let root = self.find(i);
1209            if slot[root] == usize::MAX {
1210                slot[root] = groups.len();
1211                groups.push(Vec::new());
1212            }
1213            groups[slot[root]].push(i);
1214        }
1215        groups
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use std::collections::HashSet;
1222
1223    use store_api::storage::FileId;
1224
1225    use super::*;
1226    use crate::compaction::test_util::{
1227        new_file_handle_with_size_sequence_and_primary_key_range, pk_range,
1228        primary_key_mapper_for_test,
1229    };
1230
1231    const MAX_OUTPUT: u64 = 16;
1232
1233    fn file(start: i64, end: i64, min: &[u8], max: &[u8], size: u64) -> FileHandle {
1234        new_file_handle_with_size_sequence_and_primary_key_range(
1235            FileId::random(),
1236            start,
1237            end,
1238            1,
1239            1,
1240            size,
1241            pk_range(min, max),
1242        )
1243    }
1244
1245    /// Files that all overlap each other in time and primary key.
1246    fn dense_files(count: usize) -> Vec<FileHandle> {
1247        (0..count)
1248            .map(|idx| file(0, 100, b"a", b"z", 1 + (idx % 3) as u64))
1249            .collect()
1250    }
1251
1252    /// A chain of files whose key ranges overlap their immediate neighbors.
1253    fn chain_files(count: usize, size: u64) -> Vec<FileHandle> {
1254        (0..count)
1255            .map(|idx| {
1256                let min = format!("{idx:04}");
1257                let max = format!("{:04}", idx + 2);
1258                file(0, 100, min.as_bytes(), max.as_bytes(), size)
1259            })
1260            .collect()
1261    }
1262
1263    fn limits(max_output_file_size: u64) -> PickLimits {
1264        PickLimits {
1265            max_output_file_size: Some(max_output_file_size),
1266            max_input_files: 16,
1267        }
1268    }
1269
1270    fn closed_merge(files: &[FileHandle], max_output_file_size: u64) -> Option<UnitPick> {
1271        pick_closed_merge(
1272            files,
1273            &primary_key_mapper_for_test(),
1274            closed_merge_options(max_output_file_size),
1275        )
1276    }
1277
1278    fn closed_merge_with_budget(
1279        files: &[FileHandle],
1280        max_output_file_size: u64,
1281        work_budget: usize,
1282    ) -> Option<UnitPick> {
1283        pick_closed_merge_with_budget(
1284            files,
1285            &primary_key_mapper_for_test(),
1286            closed_merge_options(max_output_file_size),
1287            work_budget,
1288        )
1289    }
1290
1291    fn closed_merge_options(max_output_file_size: u64) -> ClosedMergeOptions {
1292        ClosedMergeOptions {
1293            limits: limits(max_output_file_size),
1294            allow_file_reduction: true,
1295            allow_conflicts: true,
1296            allow_unbalanced: false,
1297        }
1298    }
1299
1300    #[test]
1301    fn test_small_l1_reduction_limits() {
1302        let mapper = primary_key_mapper_for_test();
1303        // Empty/single inputs, the small-file boundary, balance, byte and count
1304        // caps, and an unlimited output size all affect eligibility.
1305        for (sizes, max_output, max_inputs, expected) in [
1306            (vec![], Some(16), 16, 0),
1307            (vec![4], Some(16), 16, 0),
1308            (vec![4, 4], Some(16), 16, 2),
1309            (vec![5, 5], Some(16), 16, 0),
1310            (vec![4, 1], Some(16), 16, 0),
1311            (vec![4, 2, 2], Some(16), 16, 3),
1312            (vec![4; 8], Some(16), 16, 4),
1313            (vec![4; 8], Some(16), 3, 3),
1314            (vec![4, 4], Some(16), 1, 0),
1315            (vec![4, 4], Some(0), 16, 0),
1316            (vec![4, 4], None, 16, 0),
1317        ] {
1318            let files = sizes
1319                .iter()
1320                .map(|&size| file(0, 100, b"a", b"z", size))
1321                .collect::<Vec<_>>();
1322            let pick = pick_small_l1(
1323                &files,
1324                &mapper,
1325                PickLimits {
1326                    max_output_file_size: max_output,
1327                    max_input_files: max_inputs,
1328                },
1329            );
1330            assert_eq!(
1331                expected,
1332                pick.map_or(0, |pick| pick.inputs.len()),
1333                "sizes={sizes:?}, target={max_output:?}, cap={max_inputs}"
1334            );
1335        }
1336    }
1337
1338    #[test]
1339    fn test_small_l1_checks_unselected_large_and_l0_files() {
1340        let mapper = primary_key_mapper_for_test();
1341        let small = [file(0, 10, b"a", b"b", 4), file(90, 100, b"y", b"z", 4)];
1342        for level in [0, 1] {
1343            for (min, max, expected) in [(b"m", b"n", false), (b"a", b"z", true)] {
1344                let peer = new_file_handle_with_size_sequence_and_primary_key_range(
1345                    FileId::random(),
1346                    0,
1347                    100,
1348                    level,
1349                    1,
1350                    100,
1351                    pk_range(min, max),
1352                );
1353                let files = [small.to_vec(), vec![peer]].concat();
1354                let pick = pick_small_l1(&files, &mapper, limits(16));
1355                assert_eq!(
1356                    expected,
1357                    pick.is_some(),
1358                    "level={level}, range={min:?}..{max:?}"
1359                );
1360                if let Some(pick) = pick {
1361                    assert_eq!(ids(&small), ids(&pick.inputs));
1362                }
1363            }
1364        }
1365        // A small L0 must not be selected as the second member of an L1 merge.
1366        let l0 = new_file_handle_with_size_sequence_and_primary_key_range(
1367            FileId::random(),
1368            90,
1369            100,
1370            0,
1371            1,
1372            4,
1373            pk_range(b"y", b"z"),
1374        );
1375        assert!(pick_small_l1(&[small[0].clone(), l0], &mapper, limits(16)).is_none());
1376    }
1377
1378    fn ids(files: &[FileHandle]) -> HashSet<FileId> {
1379        files.iter().map(|file| file.file_id().file_id()).collect()
1380    }
1381
1382    fn extent(start: i64, end: i64, min: &[u8], max: &[u8]) -> Extent {
1383        Extent::of_file(
1384            &file(start, end, min, max, 1),
1385            &primary_key_mapper_for_test(),
1386        )
1387    }
1388
1389    #[test]
1390    fn test_conflicts_include_shared_boundary_keys() {
1391        // Independently written files may share rows at their boundary keys.
1392        assert!(extent(0, 10, b"a", b"m").conflicts(&extent(0, 10, b"m", b"z")));
1393        assert!(extent(0, 10, b"m", b"m").conflicts(&extent(0, 10, b"m", b"z")));
1394        // A writer-produced run has strictly disjoint key ranges.
1395        assert!(!extent(0, 10, b"a", b"l").conflicts(&extent(0, 10, b"m", b"z")));
1396        // A single key shared by two single-key files is a real overlap.
1397        assert!(extent(0, 10, b"m", b"m").conflicts(&extent(5, 15, b"m", b"m")));
1398        // So is a single key inside another range.
1399        assert!(extent(0, 10, b"m", b"m").conflicts(&extent(0, 10, b"a", b"z")));
1400        assert!(extent(0, 10, b"a", b"z").conflicts(&extent(0, 10, b"m", b"m")));
1401        assert!(extent(0, 10, b"a", b"n").conflicts(&extent(0, 10, b"m", b"z")));
1402        // Touching time ranges do not overlap.
1403        assert!(!extent(0, 10, b"a", b"z").conflicts(&extent(10, 20, b"a", b"z")));
1404    }
1405
1406    #[test]
1407    fn test_intrusion_covers_every_conflict_inside_an_area() {
1408        let area = extent(0, 10, b"m", b"z");
1409        // Any file sharing the area's boundary key may conflict with an output.
1410        assert!(extent(0, 10, b"m", b"m").intrudes(&area));
1411        assert!(extent(0, 10, b"a", b"m").intrudes(&area));
1412        assert!(!extent(0, 10, b"a", b"l").intrudes(&area));
1413        assert!(extent(0, 10, b"a", b"n").intrudes(&area));
1414        assert!(!extent(10, 20, b"a", b"z").intrudes(&area));
1415        // A range may conflict with a single-key file inside a single-key area.
1416        let point = extent(0, 10, b"m", b"m");
1417        assert!(extent(0, 10, b"a", b"z").intrudes(&point));
1418        assert!(extent(0, 10, b"a", b"m").intrudes(&point));
1419    }
1420
1421    #[test]
1422    fn test_intrusion_covers_conflicts_of_random_files() {
1423        let mut rng = Rng(23);
1424        let random_extent = |rng: &mut Rng| {
1425            let start = rng.next(10) as i64;
1426            let end = start + 1 + rng.next(10) as i64;
1427            let min = rng.next(6) as u8;
1428            let max = min + rng.next(3) as u8;
1429            extent(start, end, &[b'0' + min], &[b'0' + max])
1430        };
1431        for _ in 0..2_000 {
1432            let group: Vec<_> = (0..1 + rng.next(3))
1433                .map(|_| random_extent(&mut rng))
1434                .collect();
1435            let area = group[1..]
1436                .iter()
1437                .fold(group[0].clone(), |area, extent| area.union(extent));
1438            let other = random_extent(&mut rng);
1439            if group.iter().any(|extent| other.conflicts(extent)) {
1440                assert!(other.intrudes(&area), "{other:?} conflicts inside {area:?}");
1441            }
1442        }
1443    }
1444
1445    #[test]
1446    fn test_extent_index_budget_counts_nodes() {
1447        // The root's time bounds straddle the query, but neither leaf overlaps
1448        // it. A callback-only budget would never fire; the node budget must.
1449        let files = vec![file(0, 10, b"a", b"z", 1), file(90, 100, b"a", b"z", 1)];
1450        let extents = extents_of(&files, &primary_key_mapper_for_test());
1451        let index = ExtentIndex::new(&extents);
1452        let query = extent(50, 51, b"a", b"z");
1453        let mut matched = 0;
1454
1455        let mut budget = 2;
1456        assert!(!index.for_each_near_with_budget(&query, &mut budget, |_| matched += 1));
1457        assert_eq!(0, budget);
1458        assert_eq!(0, matched);
1459
1460        let mut budget = 3;
1461        assert!(index.for_each_near_with_budget(&query, &mut budget, |_| matched += 1));
1462        assert_eq!(0, budget);
1463        assert_eq!(0, matched);
1464    }
1465
1466    #[test]
1467    fn test_extent_index_matches_linear_scan() {
1468        let mut rng = Rng(29);
1469        let mapper = primary_key_mapper_for_test();
1470        for count in [0, 1, 2, 7, 64, 200] {
1471            let extents: Vec<_> = (0..count)
1472                .map(|_| {
1473                    let start = rng.next(100) as i64;
1474                    let end = start + rng.next(30) as i64;
1475                    let min = rng.next(50) as u8;
1476                    let max = min + rng.next(10) as u8;
1477                    let range = if rng.next(10) > 0 {
1478                        pk_range(&[b'0' + min], &[b'0' + max])
1479                    } else {
1480                        None
1481                    };
1482                    let file = new_file_handle_with_size_sequence_and_primary_key_range(
1483                        FileId::random(),
1484                        start,
1485                        end,
1486                        1,
1487                        1,
1488                        1,
1489                        range,
1490                    );
1491                    Extent::of_file(&file, &mapper)
1492                })
1493                .collect();
1494            let index = ExtentIndex::new(&extents);
1495            for area in &extents {
1496                let mut near = Vec::new();
1497                index.for_each_near(area, |i| near.push(i));
1498                near.sort_unstable();
1499                let expected: Vec<_> = (0..extents.len())
1500                    .filter(|&i| {
1501                        let other = &extents[i];
1502                        let keys_meet = match (&other.primary_key, &area.primary_key) {
1503                            (Some(lhs), Some(rhs)) => lhs.0 <= rhs.1 && rhs.0 <= lhs.1,
1504                            _ => true,
1505                        };
1506                        keys_meet && other.time_overlaps(area)
1507                    })
1508                    .collect();
1509                assert_eq!(expected, near, "{area:?}");
1510                for (i, other) in extents.iter().enumerate() {
1511                    if other.conflicts(area) || other.intrudes(area) {
1512                        assert!(near.binary_search(&i).is_ok(), "{other:?} near {area:?}");
1513                    }
1514                }
1515            }
1516        }
1517    }
1518
1519    #[test]
1520    fn test_active_l1_packs_pk_disjoint_files() {
1521        let files = vec![
1522            file(0, 1000, b"a", b"a", 100),
1523            file(0, 1000, b"z", b"z", 100),
1524        ];
1525        let pick = pick_active_l1(&files, &primary_key_mapper_for_test(), limits(512)).unwrap();
1526        assert_eq!(ids(&files), ids(&pick.inputs));
1527    }
1528
1529    #[test]
1530    fn test_active_l1_bounds_rewrite_cost() {
1531        let overlapping = |sizes: &[u64]| {
1532            sizes
1533                .iter()
1534                .enumerate()
1535                .map(|(idx, &size)| file(idx as i64, 100, b"a", b"z", size))
1536                .collect::<Vec<_>>()
1537        };
1538        let mapper = primary_key_mapper_for_test();
1539        assert!(pick_active_l1(&overlapping(&[300, 300, 300]), &mapper, limits(512)).is_some());
1540        // Removing one file would rewrite 4600 bytes, beyond 4 * 512.
1541        let costly = overlapping(&[500, 500, 500, 500, 500, 500, 500, 500, 500, 100]);
1542        assert!(pick_active_l1(&costly, &mapper, limits(512)).is_none());
1543    }
1544
1545    #[test]
1546    fn test_active_l1_merges_small_files_beside_dominant_file() {
1547        // All files overlap. Merging the group would rewrite the large file for
1548        // its small peers, so only the small files are merged.
1549        let mut files = vec![file(0, 1000, b"a", b"z", 1024)];
1550        files.extend((1..16).map(|start| file(start, 1000, b"a", b"z", 1)));
1551        let pick = pick_active_l1(&files, &primary_key_mapper_for_test(), limits(512)).unwrap();
1552        assert_eq!(ids(&files[1..]), ids(&pick.inputs));
1553    }
1554
1555    #[test]
1556    fn test_merges_of_a_single_key_write_one_file() {
1557        // The writer never splits a series, so the overlapping files of one key
1558        // compact into a single file however large they are.
1559        let files = vec![file(0, 100, b"m", b"m", 32), file(50, 150, b"m", b"m", 32)];
1560        let mapper = primary_key_mapper_for_test();
1561        let pick = closed_merge(&files, MAX_OUTPUT).unwrap();
1562        assert_eq!(ids(&files), ids(&pick.inputs));
1563        assert!(pick.reduces_files);
1564        let pick = pick_active_l1(&files, &mapper, limits(MAX_OUTPUT)).unwrap();
1565        assert_eq!(ids(&files), ids(&pick.inputs));
1566    }
1567
1568    #[test]
1569    fn test_closed_merge_does_not_enclose_other_files() {
1570        let a = file(0, 10, b"a", b"c", 10);
1571        let b = file(0, 10, b"x", b"z", 10);
1572        let c = file(0, 10, b"m", b"m", 1);
1573        let files = vec![a.clone(), b, c.clone()];
1574        let pick = closed_merge(&files, MAX_OUTPUT).unwrap();
1575        // Merging A and B alone would produce a file covering C.
1576        assert_eq!(ids(&[a, c]), ids(&pick.inputs));
1577        assert!(pick.reduces_files);
1578    }
1579
1580    #[test]
1581    fn test_shared_boundary_files_are_picked_without_file_reduction() {
1582        // Independently written files can hold different versions at m. Their
1583        // total size predicts two outputs, so only overlap resolution helps.
1584        let size = 512 * 1024 * 1024;
1585        let files = [
1586            file(0, 100, b"a", b"m", size),
1587            file(0, 100, b"m", b"z", size),
1588        ];
1589        let pick = closed_merge(&files, size).unwrap();
1590        assert_eq!(ids(&files), ids(&pick.inputs));
1591        assert!(!pick.reduces_files);
1592    }
1593
1594    /// A sorted run of `sizes.len()` key-sliced files, and files spanning all
1595    /// of its keys in the given time ranges, which overlap the whole run.
1596    fn run_with_late_files(
1597        sizes: &[u64],
1598        late: &[(i64, i64)],
1599    ) -> (Vec<FileHandle>, Vec<FileHandle>) {
1600        let run = sizes
1601            .iter()
1602            .enumerate()
1603            .map(|(idx, &size)| {
1604                let idx = 2 * idx as u8;
1605                file(0, 1000, &[b'a', idx], &[b'a', idx + 1], size)
1606            })
1607            .collect::<Vec<_>>();
1608        let max_key = 2 * sizes.len() as u8;
1609        let late = late
1610            .iter()
1611            .map(|&(start, end)| file(start, end, &[b'a', 0], &[b'a', max_key], 1))
1612            .collect();
1613        (run, late)
1614    }
1615
1616    #[test]
1617    fn test_closed_merge_splits_components_over_input_limit() {
1618        // The late file joins all 20 run files into one component, over the
1619        // input limit. Small run files are still merged.
1620        let (run, late) = run_with_late_files(&[1; 20], &[(100, 900)]);
1621        let files = [run, late].concat();
1622        let pick = closed_merge(&files, MAX_OUTPUT).unwrap();
1623        assert!(pick.reduces_files);
1624        assert!(pick.inputs.len() <= 16);
1625    }
1626
1627    #[test]
1628    fn test_closed_merge_leaves_late_file_over_large_run() {
1629        // Resolving the overlap would rewrite the whole run, beyond the input
1630        // limit, and merging part of it would only spread the overlap.
1631        let (run, late) = run_with_late_files(&[MAX_OUTPUT; 20], &[(100, 900)]);
1632        let files = [run, late].concat();
1633        assert!(closed_merge(&files, MAX_OUTPUT).is_none());
1634    }
1635
1636    #[test]
1637    fn test_closed_merge_compacts_late_files_over_large_run() {
1638        let (run, late) =
1639            run_with_late_files(&[MAX_OUTPUT; 20], &[(100, 200), (300, 400), (500, 600)]);
1640        let files = [run, late.clone()].concat();
1641        let pick = closed_merge(&files, MAX_OUTPUT).unwrap();
1642        assert_eq!(ids(&late), ids(&pick.inputs));
1643        assert!(pick.reduces_files);
1644    }
1645
1646    #[test]
1647    fn test_closed_merge_rejects_dominant_runs() {
1648        // Each of these components has a 3 GiB run and a 512 MiB peer.
1649        let size = 512 * 1024 * 1024;
1650        // The peer spans the keys of its run, written before or after it.
1651        let mut files = (0..6u8)
1652            .map(|idx| file(idx.into(), 10_000, &[b'b' + idx], &[b'b' + idx], size))
1653            .collect::<Vec<_>>();
1654        files.push(file(6, 10_000, b"a", b"h", size));
1655        files.extend((0..6u8).map(|idx| {
1656            file(
1657                8 + i64::from(idx),
1658                10_000,
1659                &[b'n' + idx],
1660                &[b'n' + idx],
1661                size,
1662            )
1663        }));
1664        files.push(file(7, 10_000, b"m", b"t", size));
1665        // A separate balanced pair is still resolved.
1666        let pair = [
1667            file(14, 10_000, b"x", b"y", size),
1668            file(15, 10_000, b"x", b"y", size),
1669        ];
1670        files.extend(pair.clone());
1671
1672        let pick = closed_merge(&files, size).unwrap();
1673        assert_eq!(ids(&pair), ids(&pick.inputs));
1674        assert!(!pick.reduces_files);
1675    }
1676
1677    #[test]
1678    fn test_closed_merge_ignores_touching_time_ranges() {
1679        let first = file(0, 10, b"a", b"z", 1024);
1680        let pair = [file(10, 20, b"a", b"z", 512), file(11, 19, b"a", b"z", 512)];
1681        let mut files = vec![first];
1682        files.extend(pair.clone());
1683        let pick = closed_merge(&files, 512).unwrap();
1684        assert_eq!(ids(&pair), ids(&pick.inputs));
1685    }
1686
1687    #[test]
1688    fn test_conflict_units_respects_work_budget() {
1689        let files = dense_files(20);
1690        let mapper = primary_key_mapper_for_test();
1691        let extents = extents_of(&files, &mapper);
1692        let index = ExtentIndex::new(&extents);
1693
1694        assert!(conflict_units(&files, &extents, &index, 0).is_none());
1695        assert!(conflict_units(&files, &extents, &index, usize::MAX).is_some());
1696    }
1697
1698    #[test]
1699    fn test_split_conflicts_respects_work_budget() {
1700        let files = dense_files(20);
1701        let mapper = primary_key_mapper_for_test();
1702        let extents = extents_of(&files, &mapper);
1703        let index = ExtentIndex::new(&extents);
1704        let mut units = conflict_units(&files, &extents, &index, usize::MAX).unwrap();
1705        close_units(&mut units, &extents, &index);
1706        split_oversized_units(&mut units, &files, &extents, 16);
1707        sort_units(&mut units);
1708
1709        assert_eq!(20, units.iter().filter(|unit| unit.split).count());
1710        assert!(split_conflicts(&units, &extents, &index, 0).is_none());
1711        assert!(split_conflicts(&units, &extents, &index, usize::MAX).is_some());
1712    }
1713
1714    #[test]
1715    fn test_interval_stats_exact_matches_for_each_interval() {
1716        let mapper = primary_key_mapper_for_test();
1717        // One closed unit with internal conflicts, and one oversized component
1718        // split into single-file units with cross-unit conflicts.
1719        let mut files = (0..10)
1720            .map(|_| file(0, 100, b"a", b"c", 1))
1721            .collect::<Vec<_>>();
1722        files.extend((0..20).map(|_| file(0, 100, b"x", b"z", 1)));
1723        let extents = extents_of(&files, &mapper);
1724        let index = ExtentIndex::new(&extents);
1725        let mut units = conflict_units(&files, &extents, &index, usize::MAX).unwrap();
1726        close_units(&mut units, &extents, &index);
1727        split_oversized_units(&mut units, &files, &extents, 16);
1728        sort_units(&mut units);
1729
1730        let pairs = split_conflicts(&units, &extents, &index, usize::MAX).unwrap();
1731        for_each_interval(&units, &pairs, 16, |range, stats| {
1732            let exact = interval_stats_exact(&units[range], &extents);
1733            assert_eq!(stats.num_files, exact.num_files);
1734            assert_eq!(stats.size, exact.size);
1735            assert_eq!(stats.largest_file_size, exact.largest_file_size);
1736            assert_eq!(stats.conflicts, exact.conflicts);
1737            assert_eq!(stats.single_key, exact.single_key);
1738        });
1739    }
1740
1741    #[test]
1742    fn test_active_l1_falls_back_on_dense_conflict_graph() {
1743        let files = dense_files(300);
1744        let pick = pick_active_l1_with_budget(
1745            &files,
1746            &primary_key_mapper_for_test(),
1747            limits(MAX_OUTPUT),
1748            1 << 10,
1749        )
1750        .unwrap();
1751        assert!(!pick.dense_fallback);
1752        assert!(pick.reduces_files);
1753        assert!((2..=16).contains(&pick.inputs.len()));
1754    }
1755
1756    #[rstest::rstest]
1757    #[case::both(true, true)]
1758    #[case::only_reduction(true, false)]
1759    #[case::only_overlap(false, true)]
1760    #[case::neither(false, false)]
1761    fn test_closed_merge_discovers_classes_independently(
1762        #[case] allow_reduction: bool,
1763        #[case] allow_conflicts: bool,
1764    ) {
1765        // Two disjoint groups: small neighbors reduce files, while full-size
1766        // overlapping peers only resolve conflicts. The input cap prevents a
1767        // larger interval from combining the groups into a different candidate.
1768        let files = [
1769            file(0, 10, b"a", b"b", 4),
1770            file(20, 30, b"a", b"b", 4),
1771            file(0, 30, b"c", b"d", 16),
1772            file(0, 30, b"c", b"d", 16),
1773        ];
1774        let mapper = primary_key_mapper_for_test();
1775        let options = ClosedMergeOptions {
1776            limits: PickLimits {
1777                max_input_files: 2,
1778                ..limits(MAX_OUTPUT)
1779            },
1780            allow_file_reduction: allow_reduction,
1781            allow_conflicts,
1782            allow_unbalanced: false,
1783        };
1784        let [reducing, overlap] = pick_closed_merge_by_class(&files, &mapper, options);
1785        assert_eq!(allow_reduction, reducing.is_some());
1786        assert_eq!(allow_conflicts, overlap.is_some());
1787        if let Some(pick) = reducing {
1788            assert!(pick.reduces_files);
1789            assert_eq!(ids(&files[..2]), ids(&pick.inputs));
1790        }
1791        if let Some(pick) = overlap {
1792            assert!(!pick.reduces_files);
1793            assert_eq!(ids(&files[2..]), ids(&pick.inputs));
1794        }
1795        let best = pick_closed_merge(&files, &mapper, options);
1796        let expected = if allow_reduction {
1797            Some(ids(&files[..2]))
1798        } else if allow_conflicts {
1799            Some(ids(&files[2..]))
1800        } else {
1801            None
1802        };
1803        assert_eq!(expected, best.map(|pick| ids(&pick.inputs)));
1804    }
1805
1806    #[rstest::rstest]
1807    #[case::empty(0)]
1808    #[case::single(1)]
1809    fn test_closed_merge_classes_without_enough_inputs(#[case] count: usize) {
1810        let files = dense_files(count);
1811        let picks = pick_closed_merge_by_class(
1812            &files,
1813            &primary_key_mapper_for_test(),
1814            closed_merge_options(MAX_OUTPUT),
1815        );
1816        assert!(picks.into_iter().all(|pick| pick.is_none()));
1817    }
1818
1819    #[test]
1820    fn test_closed_merge_classes_share_dense_reduction() {
1821        let files = dense_files(300);
1822        let [reducing, overlap] = pick_closed_merge_candidates(
1823            &files,
1824            &primary_key_mapper_for_test(),
1825            closed_merge_options(MAX_OUTPUT),
1826            1 << 10,
1827            true,
1828        );
1829        let pick = reducing.unwrap();
1830        assert!(pick.dense_fallback);
1831        assert!(pick.reduces_files);
1832        assert!(overlap.is_none());
1833        let legacy = closed_merge_with_budget(&files, MAX_OUTPUT, 1 << 10).unwrap();
1834        assert!(legacy.dense_fallback);
1835        assert!(legacy.reduces_files);
1836        assert!((2..=16).contains(&legacy.inputs.len()));
1837        assert_eq!(ids(&legacy.inputs), ids(&pick.inputs));
1838    }
1839
1840    #[test]
1841    fn test_dense_closed_merge_resolves_conflicts_without_file_reduction_setting() {
1842        let files = dense_files(300);
1843        let mapper = primary_key_mapper_for_test();
1844        let options = ClosedMergeOptions {
1845            allow_file_reduction: false,
1846            ..closed_merge_options(MAX_OUTPUT)
1847        };
1848        let pick = pick_closed_merge_with_budget(&files, &mapper, options, 1 << 10).unwrap();
1849        assert!(pick.dense_fallback);
1850        assert!(pick.reduces_files);
1851    }
1852
1853    #[test]
1854    fn test_dense_closed_merge_defers_when_conflicts_are_not_allowed() {
1855        let files = dense_files(300);
1856        let mapper = primary_key_mapper_for_test();
1857        let options = ClosedMergeOptions {
1858            allow_conflicts: false,
1859            ..closed_merge_options(MAX_OUTPUT)
1860        };
1861        assert!(pick_closed_merge_with_budget(&files, &mapper, options, 1 << 10).is_none());
1862    }
1863
1864    #[test]
1865    fn test_dense_closed_merge_retries_one_output_interval() {
1866        // PR #9741: the first 16 files predict two outputs but have only one
1867        // conflict with the rest of the chain. The first eight predict one
1868        // output, so rejecting the larger interval must not prevent progress.
1869        let files = chain_files(300, 2);
1870        let mapper = primary_key_mapper_for_test();
1871        let extents = extents_of(&files, &mapper);
1872        let index = ExtentIndex::new(&extents);
1873        let mut units = conflict_units(&files, &extents, &index, usize::MAX).unwrap();
1874        close_units(&mut units, &extents, &index);
1875        split_oversized_units(&mut units, &files, &extents, 16);
1876        sort_units(&mut units);
1877
1878        assert!(!adds_no_conflicts(&units[..16], &extents, &index, 2));
1879        assert!(adds_no_conflicts(&units[..8], &extents, &index, 1));
1880        // Cover both single-file units from an exhausted grouping budget and
1881        // split units from an oversized closure.
1882        for pick in [
1883            closed_merge_with_budget(&files, MAX_OUTPUT, 0).unwrap(),
1884            pick_dense_closed_merge(
1885                &units,
1886                &files,
1887                &extents,
1888                &index,
1889                closed_merge_options(MAX_OUTPUT),
1890            )
1891            .unwrap(),
1892        ] {
1893            assert_eq!(ids(&files[..8]), ids(&pick.inputs));
1894            assert!(pick.dense_fallback);
1895            assert!(pick.reduces_files);
1896            assert_eq!(300, pick.units);
1897        }
1898    }
1899
1900    #[test]
1901    fn test_dense_closed_merge_rejects_unsafe_one_output_retry() {
1902        // Intruders lie in the time gaps between inputs, so even a one-output
1903        // merge can introduce conflicts that no input previously had.
1904        let mut files: Vec<_> = (0..4)
1905            .map(|i| file(30 * i, 30 * i + 10, b"a", b"z", 6))
1906            .collect();
1907        files.extend((0..3).map(|i| file(30 * i + 15, 30 * i + 25, b"b", b"y", 1_000)));
1908
1909        assert!(closed_merge_with_budget(&files, MAX_OUTPUT, 0).is_none());
1910    }
1911
1912    /// SplitMix64, so the simulation needs no random number dependency.
1913    struct Rng(u64);
1914
1915    impl Rng {
1916        fn next(&mut self, bound: u64) -> u64 {
1917            self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
1918            let mut z = self.0;
1919            z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1920            z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1921            (z ^ (z >> 31)) % bound
1922        }
1923    }
1924
1925    /// A simulated file with its key bounds as numbers.
1926    #[derive(Clone)]
1927    struct SimFile {
1928        handle: FileHandle,
1929        min: u64,
1930        max: u64,
1931    }
1932
1933    fn sim_file(start: i64, end: i64, min: u64, max: u64, size: u64) -> SimFile {
1934        let (min_key, max_key) = (format!("{min:04}"), format!("{max:04}"));
1935        SimFile {
1936            handle: file(start, end, min_key.as_bytes(), max_key.as_bytes(), size),
1937            min,
1938            max,
1939        }
1940    }
1941
1942    /// Writes the outputs of merging `inputs` like the compactor: one stream in
1943    /// (key, time) order, split by size into consecutive files. The data is
1944    /// spread evenly over keys, and a series is never split between files.
1945    fn merge(inputs: &[SimFile], rng: &mut Rng) -> Vec<SimFile> {
1946        let min = inputs.iter().map(|file| file.min).min().unwrap();
1947        let max = inputs.iter().map(|file| file.max).max().unwrap();
1948        let start = inputs
1949            .iter()
1950            .map(|file| file.handle.time_range().0.value())
1951            .min()
1952            .unwrap();
1953        let end = inputs
1954            .iter()
1955            .map(|file| file.handle.time_range().1.value())
1956            .max()
1957            .unwrap();
1958        let size: u64 = inputs.iter().map(|file| file.handle.size()).sum();
1959        // Outputs may be fewer than predicted, never more. A single key is
1960        // never split.
1961        let predicted = if min == max {
1962            1
1963        } else {
1964            size.div_ceil(MAX_OUTPUT).max(1)
1965        };
1966        let keys = max - min + 1;
1967        let outputs = (predicted - rng.next(2).min(predicted - 1)).min(keys);
1968        (0..outputs)
1969            .map(|i| {
1970                let first_key = min + keys * i / outputs;
1971                let last_key = min + keys * (i + 1) / outputs - 1;
1972                let output_size = size * (i + 1) / outputs - size * i / outputs;
1973                sim_file(start, end, first_key, last_key, output_size)
1974            })
1975            .collect()
1976    }
1977
1978    fn conflict_pairs(files: &[FileHandle]) -> usize {
1979        let extents = extents_of(files, &primary_key_mapper_for_test());
1980        (0..extents.len())
1981            .map(|i| {
1982                (i + 1..extents.len())
1983                    .filter(|&j| extents[i].conflicts(&extents[j]))
1984                    .count()
1985            })
1986            .sum()
1987    }
1988
1989    fn random_window(rng: &mut Rng) -> Vec<SimFile> {
1990        (0..2 + rng.next(30))
1991            .map(|_| {
1992                let start = rng.next(3_000) as i64;
1993                let end = start + 1 + rng.next(600) as i64;
1994                let min = rng.next(200);
1995                let span = if rng.next(4) == 0 { 200 } else { 20 };
1996                let max = min + rng.next(span);
1997                sim_file(start, end, min, max, 1 + rng.next(2 * MAX_OUTPUT))
1998            })
1999            .collect()
2000    }
2001
2002    /// A sorted run of more files than one compaction accepts, and a few files
2003    /// spanning many of its keys, which join them into oversized components.
2004    fn oversized_window(rng: &mut Rng) -> Vec<SimFile> {
2005        let run_files = 17 + rng.next(40);
2006        let mut files: Vec<_> = (0..run_files)
2007            .map(|idx| {
2008                let start = rng.next(100) as i64;
2009                let end = 2_900 + rng.next(100) as i64;
2010                sim_file(
2011                    start,
2012                    end,
2013                    5 * idx,
2014                    5 * (idx + 1) - 1,
2015                    1 + rng.next(2 * MAX_OUTPUT),
2016                )
2017            })
2018            .collect();
2019        files.extend((0..1 + rng.next(6)).map(|_| {
2020            let start = rng.next(2_800) as i64;
2021            let end = start + 1 + rng.next(200) as i64;
2022            let min = rng.next(5 * run_files / 2);
2023            let max = min + 5 * run_files / 2 + rng.next(5 * run_files / 2);
2024            sim_file(start, end, min, max, 1 + rng.next(MAX_OUTPUT))
2025        }));
2026        files
2027    }
2028
2029    #[test]
2030    fn test_dense_closed_merge_terminates() {
2031        let mut rng = Rng(23);
2032        let mut files: Vec<_> = (0..300)
2033            .map(|_| sim_file(0, 100, 0, 8, 1 + rng.next(2 * MAX_OUTPUT)))
2034            .collect();
2035        let handles = |files: &[SimFile]| {
2036            files
2037                .iter()
2038                .map(|file| file.handle.clone())
2039                .collect::<Vec<_>>()
2040        };
2041        let mut current = (conflict_pairs(&handles(&files)), files.len());
2042        let mut steps = 0;
2043        let mut dense_steps = 0;
2044        while let Some(pick) = closed_merge_with_budget(&handles(&files), MAX_OUTPUT, 1 << 10) {
2045            assert!(pick.inputs.len() >= 2);
2046            if pick.dense_fallback {
2047                dense_steps += 1;
2048            }
2049            let picked = ids(&pick.inputs);
2050            let (inputs, kept): (Vec<_>, Vec<_>) = files
2051                .into_iter()
2052                .partition(|file| picked.contains(&file.handle.file_id().file_id()));
2053            files = kept;
2054            files.extend(merge(&inputs, &mut rng));
2055            let next = (conflict_pairs(&handles(&files)), files.len());
2056            assert!(next < current, "step {steps}: {current:?} -> {next:?}");
2057            current = next;
2058            steps += 1;
2059            assert!(steps < 1_000);
2060        }
2061        assert!(dense_steps > 0);
2062        assert!(files.len() < 300);
2063    }
2064
2065    /// Applies `pick` until it picks nothing, asserting that `measure` strictly
2066    /// decreases with every merge. Returns the number of merges.
2067    fn run_to_fixpoint(
2068        rng: &mut Rng,
2069        pick: impl Fn(&[FileHandle]) -> Option<UnitPick>,
2070        measure: impl Fn(&[FileHandle]) -> (usize, usize),
2071    ) -> usize {
2072        run_windows_to_fixpoint(rng, random_window, pick, measure)
2073    }
2074
2075    fn run_windows_to_fixpoint(
2076        rng: &mut Rng,
2077        new_window: impl Fn(&mut Rng) -> Vec<SimFile>,
2078        pick: impl Fn(&[FileHandle]) -> Option<UnitPick>,
2079        measure: impl Fn(&[FileHandle]) -> (usize, usize),
2080    ) -> usize {
2081        let mut total_steps = 0;
2082        for window in 0..300 {
2083            let mut files = new_window(rng);
2084            let handles =
2085                |files: &[SimFile]| files.iter().map(|f| f.handle.clone()).collect::<Vec<_>>();
2086            let mut current = measure(&handles(&files));
2087            let mut steps = 0;
2088            while let Some(pick) = pick(&handles(&files)) {
2089                assert!(pick.inputs.len() >= 2);
2090                let picked = ids(&pick.inputs);
2091                let (inputs, kept): (Vec<_>, Vec<_>) = files
2092                    .into_iter()
2093                    .partition(|file| picked.contains(&file.handle.file_id().file_id()));
2094                files = kept;
2095                files.extend(merge(&inputs, rng));
2096                let next = measure(&handles(&files));
2097                assert!(next < current, "window {window}: {current:?} -> {next:?}");
2098                current = next;
2099                steps += 1;
2100            }
2101            total_steps += steps;
2102        }
2103        total_steps
2104    }
2105
2106    #[test]
2107    fn test_closed_merges_terminate() {
2108        let steps = run_to_fixpoint(
2109            &mut Rng(7),
2110            |files| closed_merge(files, MAX_OUTPUT),
2111            |files| (conflict_pairs(files), files.len()),
2112        );
2113        assert!(steps > 300);
2114    }
2115
2116    #[test]
2117    fn test_closed_merges_of_oversized_components_terminate() {
2118        let steps = run_windows_to_fixpoint(
2119            &mut Rng(19),
2120            oversized_window,
2121            |files| closed_merge(files, MAX_OUTPUT),
2122            |files| (conflict_pairs(files), files.len()),
2123        );
2124        assert!(steps > 300);
2125    }
2126
2127    #[test]
2128    fn test_closed_merges_of_quiet_and_settling_windows_terminate() {
2129        let mapper = primary_key_mapper_for_test();
2130        // Quiet windows take merges a default window refuses; settling windows
2131        // skip the conflicting ones.
2132        for (seed, options, more_merges) in [
2133            (
2134                13,
2135                ClosedMergeOptions {
2136                    allow_unbalanced: true,
2137                    ..closed_merge_options(MAX_OUTPUT)
2138                },
2139                true,
2140            ),
2141            (
2142                17,
2143                ClosedMergeOptions {
2144                    allow_conflicts: false,
2145                    ..closed_merge_options(MAX_OUTPUT)
2146                },
2147                false,
2148            ),
2149        ] {
2150            let steps = run_to_fixpoint(
2151                &mut Rng(seed),
2152                |files| pick_closed_merge(files, &mapper, options),
2153                |files| (conflict_pairs(files), files.len()),
2154            );
2155            let base = run_to_fixpoint(
2156                &mut Rng(seed),
2157                |files| closed_merge(files, MAX_OUTPUT),
2158                |files| (conflict_pairs(files), files.len()),
2159            );
2160            assert_eq!(more_merges, steps > base, "seed {seed}: {steps} vs {base}");
2161        }
2162    }
2163
2164    #[test]
2165    fn test_small_l1_merges_terminate() {
2166        let mapper = primary_key_mapper_for_test();
2167        let steps = run_windows_to_fixpoint(
2168            &mut Rng(31),
2169            |rng| {
2170                let mut files = random_window(rng);
2171                // Include a reducible cluster inside a large peer as well as
2172                // random external intruders to exercise the conflict guard.
2173                files.push(sim_file(0, 3000, 0, 400, 64));
2174                files.extend((0..8).map(|_| sim_file(0, 100, 0, 8, 1 + rng.next(4))));
2175                files
2176            },
2177            |files| pick_small_l1(files, &mapper, limits(MAX_OUTPUT)),
2178            |files| (conflict_pairs(files), files.len()),
2179        );
2180        assert!(steps >= 300);
2181    }
2182
2183    #[test]
2184    fn test_active_l1_picks_terminate() {
2185        let mapper = primary_key_mapper_for_test();
2186        let steps = run_to_fixpoint(
2187            &mut Rng(11),
2188            |files| pick_active_l1(files, &mapper, limits(MAX_OUTPUT)),
2189            |files| (files.len(), 0),
2190        );
2191        assert!(steps > 200);
2192    }
2193}