Skip to main content

mito2/compaction/
twcs.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
15use std::collections::hash_map::Entry;
16use std::collections::{BTreeMap, HashMap, HashSet};
17use std::fmt::Debug;
18use std::sync::{Arc, LazyLock};
19
20use common_base::readable_size::ReadableSize;
21use common_telemetry::{debug, info};
22use common_time::Timestamp;
23use common_time::range::TimestampRange;
24use common_time::timestamp::TimeUnit;
25use common_time::timestamp_millis::BucketAligned;
26use snafu::ResultExt;
27use store_api::storage::RegionId;
28
29use crate::compaction::CompactionOutput;
30use crate::compaction::buckets::infer_time_bucket;
31use crate::compaction::compactor::CompactionRegion;
32use crate::compaction::picker::{Picker, PickerOutput, get_expired_ssts};
33use crate::compaction::run::{
34    Ranged, SortedRun, find_sorted_runs, find_sorted_runs_by_time_range, merge_primary_key_ranges,
35    primary_key_ranges_overlap,
36};
37use crate::error::{JoinSnafu, Result};
38use crate::sst::file::{FileHandle, Level, overlaps};
39use crate::sst::version::LevelMeta;
40
41const LEVEL_COMPACTED: Level = 1;
42
43#[derive(Clone, Copy, Debug)]
44enum PickPhase {
45    HasL0,
46    L1FileReduction,
47    L1OverlapOnly,
48}
49
50impl PickPhase {
51    fn applies_to(self, window: &Window) -> bool {
52        match self {
53            Self::HasL0 => window.files().any(|file| file.level() == 0),
54            Self::L1FileReduction | Self::L1OverlapOnly => {
55                window.files().any(|file| file.level() != 0)
56            }
57        }
58    }
59}
60
61const PICK_PHASES: [PickPhase; 3] = [
62    PickPhase::HasL0,
63    PickPhase::L1FileReduction,
64    PickPhase::L1OverlapOnly,
65];
66
67struct WindowPickContext<'a> {
68    active_window: Option<i64>,
69    files: &'a Window,
70    windows: &'a BTreeMap<i64, Window>,
71    phase: PickPhase,
72}
73
74struct WindowOutputContext {
75    active_window: Option<i64>,
76    time_window_size: Option<i64>,
77    max_outputs: Option<usize>,
78}
79
80/// A mixed L0/L1 compaction may rewrite at most this many L1 rows per L0 row.
81const MAX_L1_L0_ROW_RATIO: usize = 2;
82
83/// Default maximum number of input SST files in one compaction input.
84const DEFAULT_MAX_INPUT_FILES: usize = 16;
85
86const MAX_INPUT_FILES_ENV: &str = "GREPTIME_TWCS_MAX_INPUT_FILES";
87
88/// Maximum number of input SST files in one compaction input.
89/// Configurable via [`MAX_INPUT_FILES_ENV`].
90static MAX_INPUT_FILES: LazyLock<usize> = LazyLock::new(|| {
91    let env_value = std::env::var(MAX_INPUT_FILES_ENV).ok();
92    parse_max_input_files(env_value.as_deref())
93});
94
95fn parse_max_input_files(env_value: Option<&str>) -> usize {
96    env_value
97        .and_then(|env_value| env_value.parse().ok())
98        .filter(|max_input_files| *max_input_files >= 2)
99        .unwrap_or(DEFAULT_MAX_INPUT_FILES)
100}
101
102/// `TwcsPicker` picks files of which the max timestamp are in the same time window as compaction
103/// candidates.
104#[derive(Clone, Debug)]
105pub struct TwcsPicker {
106    /// Minimum file num to trigger a compaction in the active window.
107    pub trigger_file_num: usize,
108    /// Minimum L1 file num to allow a safety compaction in the active window.
109    pub active_window_l1_merge_trigger: usize,
110    /// Minimum file num to trigger a compaction in an inactive window.
111    pub inactive_window_trigger_file_num: usize,
112    /// Minimum L1 file num to trigger a compaction in an inactive window.
113    pub inactive_window_l1_merge_trigger: usize,
114    /// Compaction time window in seconds.
115    pub time_window_seconds: Option<i64>,
116    /// Max allowed compaction output file size. The picker also uses it to predict
117    /// output splits when scoring candidates.
118    pub max_output_file_size: Option<u64>,
119    /// Whether the target region is in append mode.
120    pub append_mode: bool,
121    /// Max background compaction tasks.
122    pub max_background_tasks: Option<usize>,
123    /// Optional time range that constrains candidate compaction windows.
124    pub(crate) time_range: Option<TimestampRange>,
125}
126
127impl TwcsPicker {
128    async fn build_output(
129        &self,
130        region_id: RegionId,
131        time_windows: BTreeMap<i64, Window>,
132        context: WindowOutputContext,
133    ) -> Result<Vec<CompactionOutput>> {
134        let WindowOutputContext {
135            active_window,
136            time_window_size,
137            max_outputs,
138        } = context;
139        let mut output = vec![];
140        let windows = time_windows
141            .values()
142            .rev()
143            .filter(|window| {
144                !window.files.is_empty()
145                    && self.time_range.as_ref().is_none_or(|time_range| {
146                        time_window_size.is_none_or(|time_window_size| {
147                            time_window_intersects_range(
148                                window.time_window,
149                                time_window_size,
150                                time_range,
151                            )
152                        })
153                    })
154            })
155            .map(|window| window.time_window)
156            .collect::<Vec<_>>();
157        let time_windows = Arc::new(time_windows);
158        // Enumerating all LastNonNull seeds must not increase the number of
159        // concurrently planned windows along with the output limit.
160        let chunk_size = self.max_background_tasks.unwrap_or(windows.len()).max(1);
161        let mut selected_windows = HashSet::new();
162        'phases: for phase in PICK_PHASES {
163            for chunk in windows.chunks(chunk_size) {
164                let mut handles = Vec::with_capacity(chunk.len());
165                for window in chunk {
166                    if selected_windows.contains(window) {
167                        continue;
168                    }
169                    if !time_windows
170                        .get(window)
171                        .is_some_and(|files| phase.applies_to(files))
172                    {
173                        continue;
174                    }
175                    let picker = self.clone();
176                    let time_windows = time_windows.clone();
177                    let window = *window;
178                    handles.push(common_runtime::spawn_blocking_compact(move || {
179                        time_windows.get(&window).map(|files| {
180                            (
181                                window,
182                                picker.find_inputs(
183                                    region_id,
184                                    WindowPickContext {
185                                        active_window,
186                                        files,
187                                        windows: &time_windows,
188                                        phase,
189                                    },
190                                ),
191                            )
192                        })
193                    }));
194                    tokio::task::yield_now().await;
195                }
196                for result in futures::future::join_all(handles).await {
197                    let Some((window, (inputs, filter_deleted))) = result.context(JoinSnafu)?
198                    else {
199                        continue;
200                    };
201                    if inputs.is_empty() {
202                        continue;
203                    }
204
205                    selected_windows.insert(window);
206                    output.push(CompactionOutput {
207                        output_level: LEVEL_COMPACTED, // always compact to l1
208                        inputs,
209                        filter_deleted,
210                        output_time_range: None, // we do not enforce output time range in twcs compactions.
211                    });
212
213                    if let Some(max_outputs) = max_outputs
214                        && output.len() >= max_outputs
215                    {
216                        debug!(
217                            "Region ({:?}) compaction output limit ({}) reached, remaining candidates discarded",
218                            region_id, max_outputs
219                        );
220                        break 'phases;
221                    }
222                }
223            }
224        }
225        // The compactor pops outputs from the end, preserving phase priority and
226        // newer-window-first pop order within each phase.
227        output.reverse();
228        Ok(output)
229    }
230
231    fn find_inputs(
232        &self,
233        region_id: RegionId,
234        context: WindowPickContext<'_>,
235    ) -> (Vec<FileHandle>, bool) {
236        let WindowPickContext {
237            active_window,
238            files,
239            windows,
240            phase,
241        } = context;
242        let is_active_window = active_window == Some(files.time_window);
243        let window = &files.time_window;
244        let mut files_to_merge: Vec<_> = files.files().cloned().collect();
245
246        // Filter out large files in append mode - they won't benefit from compaction
247        if self.append_mode
248            && let Some(max_size) = self.max_output_file_size
249        {
250            let (kept_files, ignored_files) = files_to_merge
251                .into_iter()
252                .partition(|file| file.size() <= max_size);
253            files_to_merge = kept_files;
254            if !ignored_files.is_empty() {
255                info!(
256                    "Skipped {} large files in append mode for region {}, window {}, max_size: {}",
257                    ignored_files.len(),
258                    region_id,
259                    window,
260                    max_size
261                );
262            }
263        }
264
265        let (l0_files, l1_files): (Vec<_>, Vec<_>) = files_to_merge
266            .into_iter()
267            .partition(|file| file.level() == 0);
268        let num_l0_files = l0_files.len();
269        let num_l1_files = l1_files.len();
270        if !is_active_window
271            && files.files.len() < self.inactive_window_trigger_file_num
272            && num_l1_files < self.inactive_window_l1_merge_trigger
273        {
274            return (vec![], false);
275        }
276        let (inputs, found_runs) = if is_active_window {
277            match phase {
278                PickPhase::HasL0 if num_l0_files >= self.trigger_file_num => {
279                    pick_candidate_files(l0_files, self.max_output_file_size, pick_count_first)
280                }
281                PickPhase::L1FileReduction | PickPhase::L1OverlapOnly
282                    if num_l1_files >= self.active_window_l1_merge_trigger =>
283                {
284                    pick_l1_candidate_files(l1_files, self.max_output_file_size, phase)
285                }
286                _ => (vec![], 0),
287            }
288        } else {
289            pick_inactive_window_files(
290                l0_files,
291                l1_files,
292                InactiveWindowPick {
293                    l0_file_num: self.inactive_window_trigger_file_num,
294                    l1_file_num: self.inactive_window_l1_merge_trigger,
295                    phase,
296                },
297                self.max_output_file_size,
298            )
299        };
300        if inputs.is_empty() {
301            return (inputs, false);
302        }
303
304        let filter_deleted = !self.append_mode
305            && !window_has_overlap(files, windows)
306            && !selected_overlaps_unselected(&inputs, files);
307
308        if inputs.len() > 1 {
309            // If we have more than one file to compact.
310            log_pick_result(
311                region_id,
312                *window,
313                active_window,
314                found_runs,
315                files.files.len(),
316                self.max_output_file_size,
317                filter_deleted,
318                &inputs,
319            );
320        }
321        (inputs, filter_deleted)
322    }
323}
324
325/// Picks compaction inputs for an inactive window.
326///
327/// The window no longer receives fresh writes (late arrivals aside), so it
328/// should converge, but merging must stay within a bounded rewrite cost:
329///
330/// 1. L0 candidates are selected before pure L1 candidates across all windows.
331/// 2. Balanced L0 picks below the trigger continue converging without pulling
332///    L1 files into the rewrite.
333/// 3. A mixed merge may bypass the balance checks, but only when the total
334///    rewrite fits in the output file budget, bounding write amplification.
335/// 4. Last resort: converge L0 files among themselves regardless of balance.
336///    The rewrite is bounded by the L0 bytes and leaves large compacted files
337///    untouched. If nothing qualifies, the window is left as-is.
338#[derive(Debug, Clone, Copy)]
339struct InactiveWindowPick {
340    l0_file_num: usize,
341    l1_file_num: usize,
342    phase: PickPhase,
343}
344
345fn pick_inactive_window_files(
346    l0_files: Vec<FileHandle>,
347    l1_files: Vec<FileHandle>,
348    pick: InactiveWindowPick,
349    max_output_file_size: Option<u64>,
350) -> (Vec<FileHandle>, usize) {
351    if !matches!(pick.phase, PickPhase::HasL0) {
352        return if l1_files.len() >= pick.l1_file_num {
353            pick_l1_candidate_files(l1_files, max_output_file_size, pick.phase)
354        } else {
355            (vec![], 0)
356        };
357    }
358
359    if l0_files.len() >= pick.l0_file_num {
360        let pick = pick_candidate_files(l0_files.clone(), max_output_file_size, pick_count_first);
361        if !pick.0.is_empty() {
362            return pick;
363        }
364    }
365
366    let pick = pick_candidate_files(l0_files.clone(), max_output_file_size, pick_count_first);
367    if !pick.0.is_empty() {
368        return pick;
369    }
370    let mut all_files = l0_files.clone();
371    all_files.extend(l1_files);
372    let pick = pick_candidate_files(all_files, max_output_file_size, pick_mixed_within_budget);
373    if !pick.0.is_empty() {
374        return pick;
375    }
376
377    pick_candidate_files(l0_files, max_output_file_size, pick_unbalanced_count_first)
378}
379
380fn pick_l1_candidate_files(
381    l1_files: Vec<FileHandle>,
382    max_output_file_size: Option<u64>,
383    phase: PickPhase,
384) -> (Vec<FileHandle>, usize) {
385    let picker = match phase {
386        PickPhase::L1FileReduction => pick_l1_file_reduction,
387        PickPhase::L1OverlapOnly => pick_l1_overlap_only,
388        PickPhase::HasL0 => return (vec![], 0),
389    };
390    pick_candidate_files(l1_files, max_output_file_size, picker)
391}
392
393fn pick_candidate_files(
394    mut files: Vec<FileHandle>,
395    max_output_file_size: Option<u64>,
396    picker: fn(Vec<SortedRun<FileHandle>>, Option<u64>) -> Vec<FileHandle>,
397) -> (Vec<FileHandle>, usize) {
398    let sorted_runs = if files.len() < 1024 {
399        find_sorted_runs(&mut files)
400    } else {
401        find_sorted_runs_by_time_range(&mut files)
402    };
403    let found_runs = sorted_runs.len();
404    (picker(sorted_runs, max_output_file_size), found_runs)
405}
406
407#[derive(Debug)]
408struct OrderedFile<'a> {
409    file: &'a FileHandle,
410    run_id: usize,
411    position_in_run: usize,
412}
413
414/// Metrics of a candidate compaction input, accumulated incrementally as the
415/// candidate interval expands one file at a time.
416#[derive(Debug, Default)]
417struct Candidate {
418    /// Number of files in the interval.
419    num_files: usize,
420    /// Total input bytes of the interval.
421    total_size: usize,
422    /// Size of the largest single file in the interval.
423    largest_file_size: usize,
424    /// Files in the interval that overlap another interval file from a different
425    /// sorted run. Resolving these overlaps is what reduces sorted runs.
426    overlap_participants: usize,
427    /// Number of rows contributed by uncompacted files.
428    l0_rows: usize,
429    /// Number of rows contributed by compacted files.
430    l1_rows: usize,
431    has_l0: bool,
432    has_l1: bool,
433    /// Whether at least one file uses 0 to represent an unknown row count.
434    has_unknown_rows: bool,
435}
436
437impl Candidate {
438    /// Absorbs `file` into the candidate. `preceding` holds the interval files added
439    /// before it and `participations` tracks which of them already participate in an
440    /// intra-interval overlap; both exist only for overlap accounting.
441    fn absorb(
442        &mut self,
443        file: &OrderedFile,
444        preceding: &[OrderedFile],
445        participations: &mut Vec<bool>,
446    ) {
447        self.num_files += 1;
448        let file_size = file.file.size() as usize;
449        self.total_size += file_size;
450        self.largest_file_size = self.largest_file_size.max(file_size);
451        self.absorb_level_rows(file.file);
452
453        let mut participates = false;
454        for (offset, other) in preceding.iter().enumerate() {
455            if file.run_id != other.run_id && file.file.overlap_inclusive(other.file) {
456                if !participations[offset] {
457                    participations[offset] = true;
458                    self.overlap_participants += 1;
459                }
460                participates = true;
461            }
462        }
463        participations.push(participates);
464        if participates {
465            self.overlap_participants += 1;
466        }
467    }
468
469    fn absorb_level_rows(&mut self, file: &FileHandle) {
470        let num_rows = file.num_rows();
471        self.has_unknown_rows |= num_rows == 0;
472        if file.level() == 0 {
473            self.has_l0 = true;
474            self.l0_rows = self.l0_rows.saturating_add(num_rows);
475        } else {
476            self.has_l1 = true;
477            self.l1_rows = self.l1_rows.saturating_add(num_rows);
478        }
479    }
480
481    /// Predicted number of output files after compacting this candidate. Compaction
482    /// output holds at most the input bytes, so a split threshold of
483    /// `max_output_file_size` yields at most `ceil(total_size / threshold)` files.
484    /// Without a threshold the output is a single file.
485    fn predicted_output_files(&self, max_output_file_size: Option<u64>) -> usize {
486        match max_output_file_size {
487            Some(max) if max > 0 => self.total_size.div_ceil(max as usize).max(1),
488            _ => 1,
489        }
490    }
491
492    /// Net file count reduction compacting this candidate would achieve, i.e. how
493    /// many fewer physical SSTs the window holds afterwards. Zero when the output
494    /// would be split back into at least as many files as the input.
495    fn file_reduction(&self, max_output_file_size: Option<u64>) -> usize {
496        self.num_files
497            .saturating_sub(self.predicted_output_files(max_output_file_size))
498    }
499
500    /// A large historical file is only rewritten once its peers contribute at least
501    /// the same amount of data, so every rewrite moves it into a larger size tier.
502    fn is_balanced(&self) -> bool {
503        self.largest_file_size <= self.total_size - self.largest_file_size
504    }
505
506    /// Bounds rewrite amplification for mixed L0/L1 candidates. Legacy files with
507    /// unknown row counts retain the byte-balance behavior above.
508    fn has_balanced_level_rows(&self) -> bool {
509        !self.has_l0
510            || !self.has_l1
511            || self.has_unknown_rows
512            || self.l1_rows <= self.l0_rows.saturating_mul(MAX_L1_L0_ROW_RATIO)
513    }
514
515    fn has_mixed_levels(&self) -> bool {
516        self.has_l0 && self.has_l1
517    }
518
519    /// Whether rewriting this candidate stays within the output file budget.
520    /// Inactive-window merges use this to bound write amplification when they
521    /// bypass the balance checks. An unset budget means no bound: the operator
522    /// removed the output size limit explicitly.
523    fn within_rewrite_budget(&self, max_output_file_size: Option<u64>) -> bool {
524        match max_output_file_size {
525            Some(limit) if limit > 0 => self.total_size <= limit as usize,
526            _ => true,
527        }
528    }
529
530    /// A candidate is worth compacting only if it makes progress on at least one
531    /// axis: it reduces the physical file count, or it resolves at least one
532    /// overlap (merging sorted runs and reducing read amplification). A pure
533    /// rewrite that achieves neither only burns I/O.
534    fn makes_progress(&self, max_output_file_size: Option<u64>) -> bool {
535        self.file_reduction(max_output_file_size) > 0 || self.overlap_participants > 0
536    }
537
538    fn score(&self, max_output_file_size: Option<u64>) -> CandidateScore {
539        CandidateScore {
540            file_reduction: self.file_reduction(max_output_file_size),
541            overlap_participants: self.overlap_participants,
542            total_size: self.total_size,
543        }
544    }
545}
546
547/// Score of a candidate compaction input. Higher is better: first by the predicted
548/// net file count reduction, then by the number of files participating in an
549/// overlap, then by smaller input bytes.
550#[derive(Debug)]
551struct CandidateScore {
552    file_reduction: usize,
553    overlap_participants: usize,
554    total_size: usize,
555}
556
557impl CandidateScore {
558    fn is_better_than(&self, other: &Self) -> bool {
559        self.file_reduction
560            .cmp(&other.file_reduction)
561            .then_with(|| self.overlap_participants.cmp(&other.overlap_participants))
562            .then_with(|| other.total_size.cmp(&self.total_size))
563            .is_gt()
564    }
565}
566
567/// Picks a contiguous (in global time order) interval of files to compact.
568///
569/// The picker reorders all files from all sorted runs by `(start asc, end desc)` and
570/// enumerates every interval of at most [`MAX_INPUT_FILES`] files as a [`Candidate`].
571/// An interval is eligible when it
572///
573/// - holds at least 2 files,
574/// - passes the caller-supplied eligibility predicate (e.g. the byte- and
575///   row-balance checks for regular picks, the rewrite budget for inactive
576///   window fallbacks),
577/// - makes progress on at least one axis: it reduces the physical file count given
578///   the output split threshold `max_output_file_size`, or it resolves at least one
579///   overlap between sorted runs.
580///
581/// The best interval wins by [`CandidateScore`]; ties keep the earliest interval.
582fn pick_count_first(
583    sorted_runs: Vec<SortedRun<FileHandle>>,
584    max_output_file_size: Option<u64>,
585) -> Vec<FileHandle> {
586    pick_count_first_where(sorted_runs, max_output_file_size, is_balanced_candidate)
587}
588
589fn pick_l1_file_reduction(
590    sorted_runs: Vec<SortedRun<FileHandle>>,
591    max_output_file_size: Option<u64>,
592) -> Vec<FileHandle> {
593    pick_count_first_where(sorted_runs, max_output_file_size, |candidate| {
594        is_balanced_candidate(candidate) && candidate.file_reduction(max_output_file_size) > 0
595    })
596}
597
598fn pick_l1_overlap_only(
599    sorted_runs: Vec<SortedRun<FileHandle>>,
600    max_output_file_size: Option<u64>,
601) -> Vec<FileHandle> {
602    pick_count_first_where(sorted_runs, max_output_file_size, |candidate| {
603        is_balanced_candidate(candidate)
604            && candidate.file_reduction(max_output_file_size) == 0
605            && candidate.overlap_participants > 0
606    })
607}
608
609#[cfg(test)]
610fn pick_mixed_count_first(
611    sorted_runs: Vec<SortedRun<FileHandle>>,
612    max_output_file_size: Option<u64>,
613) -> Vec<FileHandle> {
614    pick_count_first_where(sorted_runs, max_output_file_size, |candidate| {
615        candidate.has_mixed_levels() && is_balanced_candidate(candidate)
616    })
617}
618
619/// Mixed-level fallback for inactive windows: bypasses the balance checks but
620/// bounds the total rewrite to the output file budget.
621fn pick_mixed_within_budget(
622    sorted_runs: Vec<SortedRun<FileHandle>>,
623    max_output_file_size: Option<u64>,
624) -> Vec<FileHandle> {
625    pick_count_first_where(sorted_runs, max_output_file_size, |candidate| {
626        candidate.has_mixed_levels() && candidate.within_rewrite_budget(max_output_file_size)
627    })
628}
629
630/// Last-resort pick for inactive windows with no balance requirement at all.
631/// Callers must only pass single-level (L0) files so the rewrite stays bounded
632/// by their bytes.
633fn pick_unbalanced_count_first(
634    sorted_runs: Vec<SortedRun<FileHandle>>,
635    max_output_file_size: Option<u64>,
636) -> Vec<FileHandle> {
637    pick_count_first_where(sorted_runs, max_output_file_size, |_| true)
638}
639
640fn is_balanced_candidate(candidate: &Candidate) -> bool {
641    candidate.is_balanced() && candidate.has_balanced_level_rows()
642}
643
644fn pick_count_first_where(
645    sorted_runs: Vec<SortedRun<FileHandle>>,
646    max_output_file_size: Option<u64>,
647    is_eligible: impl Fn(&Candidate) -> bool,
648) -> Vec<FileHandle> {
649    let files = ordered_files(&sorted_runs);
650
651    let mut best = None;
652    for left in 0..files.len() {
653        let mut candidate = Candidate::default();
654        let right_bound = left.saturating_add(*MAX_INPUT_FILES).min(files.len());
655        let mut participations: Vec<bool> = Vec::with_capacity(right_bound - left);
656        for right in left..right_bound {
657            candidate.absorb(&files[right], &files[left..right], &mut participations);
658            if candidate.num_files < 2
659                || !is_eligible(&candidate)
660                || !candidate.makes_progress(max_output_file_size)
661            {
662                continue;
663            }
664
665            let score = candidate.score(max_output_file_size);
666            if best
667                .as_ref()
668                .is_none_or(|(best_score, _)| score.is_better_than(best_score))
669            {
670                best = Some((score, &files[left..=right]));
671            }
672        }
673    }
674
675    let Some((_, best)) = best else {
676        return vec![];
677    };
678    best.iter().map(|file| file.file.clone()).collect()
679}
680
681/// Flattens sorted runs into files ordered by `(start asc, end desc)`, breaking ties
682/// by run and position to keep the order deterministic.
683fn ordered_files(sorted_runs: &[SortedRun<FileHandle>]) -> Vec<OrderedFile<'_>> {
684    let mut files = sorted_runs
685        .iter()
686        .enumerate()
687        .flat_map(|(run_id, run)| {
688            run.items()
689                .iter()
690                .enumerate()
691                .map(move |(position_in_run, file)| OrderedFile {
692                    file,
693                    run_id,
694                    position_in_run,
695                })
696        })
697        .collect::<Vec<_>>();
698    files.sort_unstable_by(|lhs, rhs| {
699        let (lhs_start, lhs_end) = lhs.file.range();
700        let (rhs_start, rhs_end) = rhs.file.range();
701        lhs_start
702            .cmp(&rhs_start)
703            .then_with(|| rhs_end.cmp(&lhs_end))
704            .then_with(|| lhs.run_id.cmp(&rhs.run_id))
705            .then_with(|| lhs.position_in_run.cmp(&rhs.position_in_run))
706    });
707    files
708}
709
710fn selected_overlaps_unselected(selected: &[FileHandle], window: &Window) -> bool {
711    // The overall time span of the selection: a file outside it cannot overlap any
712    // selected file (ranges are inclusive), so it needs no precise overlap check.
713    let Some((span_start, span_end)) = selected
714        .iter()
715        .map(Ranged::range)
716        .reduce(|(start_a, end_a), (start_b, end_b)| (start_a.min(start_b), end_a.max(end_b)))
717    else {
718        return false;
719    };
720    let selected_file_ids = selected
721        .iter()
722        .map(FileHandle::file_id)
723        .collect::<HashSet<_>>();
724    window
725        .files()
726        .filter(|file| {
727            let (start, end) = file.range();
728            start <= span_end && span_start <= end
729        })
730        .filter(|file| !selected_file_ids.contains(&file.file_id()))
731        .any(|unselected| {
732            selected
733                .iter()
734                .any(|selected| selected.overlap_inclusive(unselected))
735        })
736}
737
738#[allow(clippy::too_many_arguments)]
739fn log_pick_result(
740    region_id: RegionId,
741    window: i64,
742    active_window: Option<i64>,
743    found_runs: usize,
744    file_num: usize,
745    max_output_file_size: Option<u64>,
746    filter_deleted: bool,
747    inputs: &[FileHandle],
748) {
749    let input_file_str: Vec<String> = inputs
750        .iter()
751        .map(|f| {
752            let range = f.range();
753            let start = range.0.to_iso8601_string();
754            let end = range.1.to_iso8601_string();
755            let num_rows = f.num_rows();
756            format!(
757                "File{{id: {:?}, range: ({}, {}), size: {}, num rows: {} }}",
758                f.file_id(),
759                start,
760                end,
761                ReadableSize(f.size()),
762                num_rows
763            )
764        })
765        .collect();
766    let window_str = Timestamp::new_second(window).to_iso8601_string();
767    let active_window_str = active_window.map(|s| Timestamp::new_second(s).to_iso8601_string());
768    let max_output_file_size = max_output_file_size.map(|size| ReadableSize(size).to_string());
769    info!(
770        "Region ({:?}) compaction pick result: current window: {}, active window: {:?}, \
771            found runs: {}, file num: {}, max output file size: {:?}, filter deleted: {}, \
772            input files: {:?}",
773        region_id,
774        window_str,
775        active_window_str,
776        found_runs,
777        file_num,
778        max_output_file_size,
779        filter_deleted,
780        input_file_str
781    );
782}
783
784#[async_trait::async_trait]
785impl Picker for TwcsPicker {
786    async fn pick(&self, compaction_region: &CompactionRegion) -> Result<Option<PickerOutput>> {
787        self.pick_with_output_limit(compaction_region, self.max_background_tasks)
788            .await
789    }
790}
791
792impl TwcsPicker {
793    pub(crate) async fn pick_with_output_limit(
794        &self,
795        compaction_region: &CompactionRegion,
796        max_outputs: Option<usize>,
797    ) -> Result<Option<PickerOutput>> {
798        let region_id = compaction_region.region_id;
799        let picker = self.clone();
800        let compaction_region = compaction_region.clone();
801        let (expired_ssts, time_window_size, active_window, windows) =
802            common_runtime::spawn_blocking_compact(move || {
803                let levels = compaction_region.current_version.ssts.levels();
804                let expired_ssts = get_expired_ssts(
805                    levels,
806                    compaction_region.ttl,
807                    Timestamp::current_millis(),
808                );
809                if !expired_ssts.is_empty() {
810                    info!("Expired SSTs in region {}: {:?}", region_id, expired_ssts);
811                }
812                let expired_file_ids = expired_ssts
813                    .iter()
814                    .map(|file| file.file_id())
815                    .collect::<HashSet<_>>();
816
817                let compaction_time_window = compaction_region
818                    .current_version
819                    .compaction_time_window
820                    .map(|window| window.as_secs() as i64);
821                let time_window_size = compaction_time_window
822                    .or(picker.time_window_seconds)
823                    .unwrap_or_else(|| {
824                        let inferred = infer_time_bucket(levels[0].files());
825                        info!(
826                            "Compaction window for region {} is not present, inferring from files: {:?}",
827                            region_id, inferred
828                        );
829                        inferred
830                    });
831
832                let windows = assign_to_windows(
833                    levels
834                        .iter()
835                        .flat_map(LevelMeta::files)
836                        .filter(|file| !expired_file_ids.contains(&file.file_id())),
837                    time_window_size,
838                );
839                // Compute activity from the candidate files so expired or
840                // compacting files cannot identify a window absent from `windows`.
841                let active_window = find_active_window_by_sequence(
842                    windows.values().flat_map(Window::files),
843                    time_window_size,
844                )
845                .or_else(|| {
846                    find_latest_window_in_seconds(
847                        windows
848                            .values()
849                            .flat_map(Window::files)
850                            .filter(|file| file.level() == 0),
851                        time_window_size,
852                    )
853                });
854
855                (expired_ssts, time_window_size, active_window, windows)
856            })
857            .await
858            .context(JoinSnafu)?;
859
860        let outputs = self
861            .build_output(
862                region_id,
863                windows,
864                WindowOutputContext {
865                    active_window,
866                    time_window_size: Some(time_window_size),
867                    max_outputs,
868                },
869            )
870            .await?;
871
872        if outputs.is_empty() && expired_ssts.is_empty() {
873            return Ok(None);
874        }
875
876        // The picker treats zero as unlimited, but the SST writer would split every batch.
877        let max_file_size = self
878            .max_output_file_size
879            .filter(|size| *size > 0)
880            .map(|size| size as usize);
881        Ok(Some(PickerOutput {
882            outputs,
883            expired_ssts,
884            time_window_size,
885            max_file_size,
886        }))
887    }
888}
889
890#[derive(Clone)]
891struct Window {
892    start: Timestamp,
893    end: Timestamp,
894    files: Vec<FileHandle>,
895    time_window: i64,
896    primary_key_range: Option<(bytes::Bytes, bytes::Bytes)>,
897}
898
899impl Window {
900    /// Creates a new [Window] with given file.
901    fn new_with_file(file: FileHandle) -> Self {
902        let (start, end) = file.time_range();
903        let primary_key_range = file.primary_key_range();
904        Self {
905            start,
906            end,
907            files: vec![file],
908            time_window: 0,
909            primary_key_range,
910        }
911    }
912
913    /// Returns the time range of all files in current window (inclusive).
914    fn range(&self) -> (Timestamp, Timestamp) {
915        (self.start, self.end)
916    }
917
918    /// Adds a new file to window and updates time range.
919    fn add_file(&mut self, file: FileHandle) {
920        let (start, end) = file.time_range();
921        self.start = self.start.min(start);
922        self.end = self.end.max(end);
923        self.primary_key_range =
924            merge_primary_key_ranges(self.primary_key_range.take(), file.primary_key_range());
925        self.files.push(file);
926    }
927
928    fn files(&self) -> impl Iterator<Item = &FileHandle> {
929        self.files.iter()
930    }
931}
932
933/// Assigns files to windows with predefined window size (in seconds) by their max timestamps.
934fn assign_to_windows<'a>(
935    files: impl Iterator<Item = &'a FileHandle>,
936    time_window_size: i64,
937) -> BTreeMap<i64, Window> {
938    let mut windows: HashMap<i64, Window> = HashMap::new();
939    // Iterates all files and assign to time windows according to max timestamp
940    for f in files {
941        if f.compacting() {
942            continue;
943        }
944        let (_, end) = f.time_range();
945        let time_window = end
946            .convert_to(TimeUnit::Second)
947            .unwrap()
948            .value()
949            .align_to_ceil_by_bucket(time_window_size)
950            .unwrap_or(i64::MIN);
951
952        match windows.entry(time_window) {
953            Entry::Occupied(mut e) => {
954                e.get_mut().add_file(f.clone());
955            }
956            Entry::Vacant(e) => {
957                let mut window = Window::new_with_file(f.clone());
958                window.time_window = time_window;
959                e.insert(window);
960            }
961        }
962    }
963    windows.into_iter().collect()
964}
965
966fn time_window_intersects_range(
967    window_end: i64,
968    time_window_size: i64,
969    time_range: &TimestampRange,
970) -> bool {
971    let first_window = match time_range.start() {
972        None => i64::MIN,
973        Some(start) => {
974            let Some(first_window) = start
975                .convert_to(TimeUnit::Second)
976                .and_then(|timestamp| timestamp.value().align_to_ceil_by_bucket(time_window_size))
977            else {
978                return false;
979            };
980            first_window
981        }
982    };
983    let last_window = match time_range.end() {
984        None => i64::MAX,
985        Some(end) => {
986            let Some(last_window) = end
987                .convert_to_ceil(TimeUnit::Second)
988                .and_then(|timestamp| timestamp.value().checked_sub(1))
989                .and_then(|timestamp| timestamp.align_to_ceil_by_bucket(time_window_size))
990            else {
991                return false;
992            };
993            last_window
994        }
995    };
996    (first_window..=last_window).contains(&window_end)
997}
998
999fn window_has_overlap(this: &Window, windows: &BTreeMap<i64, Window>) -> bool {
1000    windows
1001        .values()
1002        .filter(|that| this.time_window != that.time_window)
1003        .any(|that| {
1004            overlaps(&this.range(), &that.range()) && {
1005                match (&this.primary_key_range, &that.primary_key_range) {
1006                    (Some(l), Some(r)) => primary_key_ranges_overlap(l, r),
1007                    _ => true,
1008                }
1009            }
1010        })
1011}
1012
1013/// Finds the latest active writing window among all files.
1014/// Returns `None` when there are no files or all files are corrupted.
1015fn find_latest_window_in_seconds<'a>(
1016    files: impl Iterator<Item = &'a FileHandle>,
1017    time_window_size: i64,
1018) -> Option<i64> {
1019    let mut latest_timestamp = None;
1020    for f in files {
1021        let (_, end) = f.time_range();
1022        if let Some(latest) = latest_timestamp {
1023            if end > latest {
1024                latest_timestamp = Some(end);
1025            }
1026        } else {
1027            latest_timestamp = Some(end);
1028        }
1029    }
1030    latest_timestamp
1031        .and_then(|ts| ts.convert_to_ceil(TimeUnit::Second))
1032        .and_then(|ts| ts.value().align_to_ceil_by_bucket(time_window_size))
1033}
1034
1035/// Finds the active window from the file with the highest sequence number.
1036///
1037/// Flush and compaction outputs inherit the max input sequence, so the
1038/// max-sequence file always tracks the most recent write: the active window
1039/// survives the transient state where level 0 is empty right after its files
1040/// were compacted away. Returns `None` when no file carries a sequence.
1041///
1042/// The window key follows the same convention as [`assign_to_windows`]
1043/// (truncate to seconds, then align up), since it is compared against the
1044/// window keys produced there.
1045fn find_active_window_by_sequence<'a>(
1046    files: impl Iterator<Item = &'a FileHandle>,
1047    time_window_size: i64,
1048) -> Option<i64> {
1049    files
1050        .filter(|f| f.meta_ref().sequence.is_some())
1051        .max_by_key(|f| f.meta_ref().sequence)
1052        .and_then(|f| {
1053            f.time_range()
1054                .1
1055                .convert_to(TimeUnit::Second)
1056                .and_then(|ts| ts.value().align_to_ceil_by_bucket(time_window_size))
1057        })
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use std::collections::HashSet;
1063    use std::num::NonZeroU64;
1064    use std::sync::Arc;
1065    use std::time::Duration;
1066
1067    use bytes::Bytes;
1068    use common_base::Plugins;
1069    use common_time::range::TimestampRange;
1070    use store_api::storage::FileId;
1071
1072    use super::*;
1073    use crate::cache::CacheManager;
1074    use crate::compaction::compactor::CompactionVersion;
1075    use crate::compaction::test_util::{
1076        compaction_region_with_ssts, new_file_handle, new_file_handle_with_sequence,
1077        new_file_handle_with_size_and_sequence,
1078        new_file_handle_with_size_sequence_and_primary_key_range,
1079    };
1080    use crate::config::MitoConfig;
1081    use crate::region::options::RegionOptions;
1082    use crate::sst::file::{FileMeta, Level};
1083    use crate::sst::version::SstVersion;
1084    use crate::test_util::memtable_util::metadata_for_test;
1085    use crate::test_util::scheduler_util::SchedulerEnv;
1086
1087    impl TwcsPicker {
1088        async fn build_output_with_time_range(
1089            &self,
1090            region_id: RegionId,
1091            time_windows: BTreeMap<i64, Window>,
1092            active_window: Option<i64>,
1093            time_window_size: Option<i64>,
1094        ) -> Result<Vec<CompactionOutput>> {
1095            self.build_output(
1096                region_id,
1097                time_windows,
1098                WindowOutputContext {
1099                    active_window,
1100                    time_window_size,
1101                    max_outputs: self.max_background_tasks,
1102                },
1103            )
1104            .await
1105        }
1106    }
1107
1108    #[test]
1109    fn test_valid_max_input_files_env_overrides_default() {
1110        assert_eq!(64, parse_max_input_files(Some("64")));
1111    }
1112
1113    #[test]
1114    fn test_invalid_max_input_files_env_falls_back_to_default() {
1115        for env_value in [None, Some(""), Some("invalid"), Some("0"), Some("1")] {
1116            assert_eq!(16, parse_max_input_files(env_value));
1117        }
1118    }
1119
1120    async fn compaction_region_with_expired_sst() -> CompactionRegion {
1121        compaction_region_with_ssts(
1122            (1..=4).map(|sequence| FileMeta {
1123                file_id: FileId::random(),
1124                time_range: (
1125                    Timestamp::new_millisecond(0),
1126                    Timestamp::new_millisecond(10),
1127                ),
1128                level: 0,
1129                sequence: NonZeroU64::new(sequence),
1130                ..Default::default()
1131            }),
1132            Duration::from_millis(1),
1133        )
1134        .await
1135    }
1136
1137    #[tokio::test]
1138    async fn test_pick_normalizes_zero_output_size_to_unlimited() {
1139        let mut compaction_region = compaction_region_with_ssts(
1140            (1..=4).map(|sequence| new_file(0, 10, sequence, 100).meta_ref().clone()),
1141            Duration::from_secs(3600),
1142        )
1143        .await;
1144        compaction_region.ttl = None;
1145
1146        for (max_output_file_size, expected_max_file_size) in
1147            [(Some(0), None), (None, None), (Some(1024), Some(1024))]
1148        {
1149            let picker = TwcsPicker {
1150                trigger_file_num: 4,
1151                active_window_l1_merge_trigger: 8,
1152                inactive_window_trigger_file_num: 4,
1153                inactive_window_l1_merge_trigger: 8,
1154                time_window_seconds: Some(3),
1155                max_output_file_size,
1156                append_mode: false,
1157                max_background_tasks: None,
1158                time_range: None,
1159            };
1160
1161            let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1162
1163            assert_eq!(output.outputs.len(), 1);
1164            assert_eq!(output.outputs[0].inputs.len(), 4);
1165            assert!(output.expired_ssts.is_empty());
1166            assert_eq!(output.max_file_size, expected_max_file_size);
1167        }
1168    }
1169
1170    #[tokio::test]
1171    async fn test_pick_expired_ssts_without_marking_compacting() {
1172        let picker = TwcsPicker {
1173            trigger_file_num: 4,
1174            active_window_l1_merge_trigger: 8,
1175            inactive_window_trigger_file_num: 4,
1176            inactive_window_l1_merge_trigger: 8,
1177            time_window_seconds: Some(3),
1178            max_output_file_size: None,
1179            append_mode: false,
1180            max_background_tasks: None,
1181            time_range: None,
1182        };
1183        let compaction_region = compaction_region_with_expired_sst().await;
1184
1185        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1186
1187        assert!(output.outputs.is_empty());
1188        assert!(!output.expired_ssts.is_empty());
1189        assert!(output.expired_ssts.iter().all(|file| !file.compacting()));
1190    }
1191
1192    #[tokio::test]
1193    async fn test_expired_sst_does_not_determine_active_window() {
1194        let now = Timestamp::current_millis().value();
1195        let files = [
1196            FileMeta {
1197                file_id: FileId::random(),
1198                time_range: (
1199                    Timestamp::new_millisecond(0),
1200                    Timestamp::new_millisecond(10),
1201                ),
1202                level: 0,
1203                sequence: NonZeroU64::new(100),
1204                ..Default::default()
1205            },
1206            FileMeta {
1207                file_id: FileId::random(),
1208                time_range: (
1209                    Timestamp::new_millisecond(now - 1000),
1210                    Timestamp::new_millisecond(now),
1211                ),
1212                level: 0,
1213                sequence: NonZeroU64::new(1),
1214                ..Default::default()
1215            },
1216            FileMeta {
1217                file_id: FileId::random(),
1218                time_range: (
1219                    Timestamp::new_millisecond(now - 1000),
1220                    Timestamp::new_millisecond(now),
1221                ),
1222                level: 0,
1223                sequence: NonZeroU64::new(2),
1224                ..Default::default()
1225            },
1226        ];
1227        let compaction_region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
1228        let picker = TwcsPicker {
1229            trigger_file_num: 4,
1230            active_window_l1_merge_trigger: 8,
1231            inactive_window_trigger_file_num: 2,
1232            inactive_window_l1_merge_trigger: 8,
1233            time_window_seconds: Some(3),
1234            max_output_file_size: None,
1235            append_mode: false,
1236            max_background_tasks: None,
1237            time_range: None,
1238        };
1239
1240        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1241
1242        assert_eq!(1, output.expired_ssts.len());
1243        assert!(output.outputs.is_empty());
1244    }
1245
1246    #[tokio::test]
1247    async fn test_active_window_survives_l0_consumption() {
1248        // Simulates the transient state right after an L0 compaction: level 0 is
1249        // empty and only L1 files remain. The active window must still be the
1250        // window of the most recent write (tracked by the max-sequence file), so
1251        // the window is NOT compacted under the inactive rules.
1252        let env = SchedulerEnv::new().await;
1253        let metadata = metadata_for_test();
1254        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
1255        let mut ssts = SstVersion::new();
1256        ssts.add_files(
1257            Arc::new(crate::sst::file_purger::NoopFilePurger),
1258            [100, 101].into_iter().map(|sequence| FileMeta {
1259                file_id: FileId::random(),
1260                time_range: (
1261                    Timestamp::new_millisecond(0),
1262                    Timestamp::new_millisecond(10),
1263                ),
1264                level: 1,
1265                sequence: NonZeroU64::new(sequence),
1266                ..Default::default()
1267            }),
1268        );
1269        let compaction_region = CompactionRegion {
1270            region_id: metadata.region_id,
1271            region_options: RegionOptions::default(),
1272            engine_config: Arc::new(MitoConfig::default()),
1273            region_metadata: metadata.clone(),
1274            cache_manager: Arc::new(CacheManager::default()),
1275            access_layer: env.access_layer,
1276            manifest_ctx,
1277            current_version: CompactionVersion {
1278                metadata,
1279                options: RegionOptions::default(),
1280                ssts: Arc::new(ssts),
1281                memtable_min_sequence: None,
1282                compaction_time_window: None,
1283            },
1284            file_purger: None,
1285            ttl: None,
1286            max_parallelism: 1,
1287            plugins: Plugins::new(),
1288        };
1289        let picker = TwcsPicker {
1290            trigger_file_num: 4,
1291            active_window_l1_merge_trigger: 8,
1292            inactive_window_trigger_file_num: 2,
1293            inactive_window_l1_merge_trigger: 8,
1294            time_window_seconds: Some(3600),
1295            max_output_file_size: None,
1296            append_mode: false,
1297            max_background_tasks: None,
1298            time_range: None,
1299        };
1300
1301        assert!(picker.pick(&compaction_region).await.unwrap().is_none());
1302    }
1303
1304    #[test]
1305    fn test_find_active_window_by_sequence() {
1306        // The max-sequence file tracks the most recent write regardless of
1307        // level: here a compacted L1 file ending at 2999ms (window key 2) is
1308        // newer than the L0 file ending at 999ms (window key 0). Window keys
1309        // follow the assign_to_windows convention (truncate to seconds).
1310        let files = [
1311            new_file_handle_with_sequence(FileId::random(), 0, 999, 0, 1),
1312            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 1, 10),
1313        ];
1314        assert_eq!(Some(2), find_active_window_by_sequence(files.iter(), 1));
1315
1316        // Backfill: the newest write lands in an older window, and that window
1317        // is the active one.
1318        let files = [
1319            new_file_handle_with_sequence(FileId::random(), 0, 999, 0, 10),
1320            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 0, 1),
1321        ];
1322        assert_eq!(Some(0), find_active_window_by_sequence(files.iter(), 1));
1323
1324        // Files without a sequence are skipped; if none have one, the caller
1325        // falls back to the L0-based rule.
1326        let files = [new_file_handle_with_sequence(
1327            FileId::random(),
1328            0,
1329            999,
1330            1,
1331            0,
1332        )];
1333        assert_eq!(None, find_active_window_by_sequence(files.iter(), 1));
1334        assert!(find_active_window_by_sequence(Vec::<FileHandle>::new().iter(), 1).is_none());
1335    }
1336
1337    #[test]
1338    fn test_active_window_falls_back_to_l0_rule_without_sequence() {
1339        let active_window_of = |files: &[FileHandle]| {
1340            find_active_window_by_sequence(files.iter(), 1).or_else(|| {
1341                find_latest_window_in_seconds(files.iter().filter(|f| f.level() == 0), 1)
1342            })
1343        };
1344
1345        // Legacy files without sequence: the L0-based rule still applies.
1346        let files = [
1347            new_file_handle_with_sequence(FileId::random(), 0, 999, 1, 0),
1348            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 0, 0),
1349        ];
1350        assert_eq!(Some(3), active_window_of(&files));
1351
1352        // No sequence and no L0: None, exactly as before.
1353        let files = [new_file_handle_with_sequence(
1354            FileId::random(),
1355            0,
1356            999,
1357            1,
1358            0,
1359        )];
1360        assert_eq!(None, active_window_of(&files));
1361    }
1362
1363    #[test]
1364    fn test_get_latest_window_in_seconds() {
1365        assert_eq!(
1366            Some(1),
1367            find_latest_window_in_seconds([new_file_handle(FileId::random(), 0, 999, 0)].iter(), 1)
1368        );
1369        assert_eq!(
1370            Some(1),
1371            find_latest_window_in_seconds(
1372                [new_file_handle(FileId::random(), 0, 1000, 0)].iter(),
1373                1
1374            )
1375        );
1376
1377        assert_eq!(
1378            Some(-9223372036854000),
1379            find_latest_window_in_seconds(
1380                [new_file_handle(FileId::random(), i64::MIN, i64::MIN + 1, 0)].iter(),
1381                3600,
1382            )
1383        );
1384
1385        assert_eq!(
1386            (i64::MAX / 10000000 + 1) * 10000,
1387            find_latest_window_in_seconds(
1388                [new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0)].iter(),
1389                10000,
1390            )
1391            .unwrap()
1392        );
1393
1394        assert_eq!(
1395            Some((i64::MAX / 3600000 + 1) * 3600),
1396            find_latest_window_in_seconds(
1397                [
1398                    new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0),
1399                    new_file_handle(FileId::random(), 0, 1000, 0)
1400                ]
1401                .iter(),
1402                3600
1403            )
1404        );
1405    }
1406
1407    #[test]
1408    fn test_assign_to_windows() {
1409        let windows = assign_to_windows(
1410            [
1411                new_file_handle(FileId::random(), 0, 999, 0),
1412                new_file_handle(FileId::random(), 0, 999, 0),
1413                new_file_handle(FileId::random(), 0, 999, 0),
1414                new_file_handle(FileId::random(), 0, 999, 0),
1415                new_file_handle(FileId::random(), 0, 999, 0),
1416            ]
1417            .iter(),
1418            3,
1419        );
1420        let fgs = &windows.get(&0).unwrap().files;
1421        assert_eq!(5, fgs.len());
1422
1423        let files = [FileId::random(); 3];
1424        let windows = assign_to_windows(
1425            [
1426                new_file_handle(files[0], -2000, -3, 0),
1427                new_file_handle(files[1], 0, 2999, 0),
1428                new_file_handle(files[2], 50, 10001, 0),
1429            ]
1430            .iter(),
1431            3,
1432        );
1433        assert_eq!(
1434            files[0],
1435            windows
1436                .get(&0)
1437                .unwrap()
1438                .files()
1439                .next()
1440                .unwrap()
1441                .file_id()
1442                .file_id()
1443        );
1444        assert_eq!(
1445            files[1],
1446            windows
1447                .get(&3)
1448                .unwrap()
1449                .files()
1450                .next()
1451                .unwrap()
1452                .file_id()
1453                .file_id()
1454        );
1455        assert_eq!(
1456            files[2],
1457            windows
1458                .get(&12)
1459                .unwrap()
1460                .files()
1461                .next()
1462                .unwrap()
1463                .file_id()
1464                .file_id()
1465        );
1466    }
1467
1468    #[test]
1469    fn test_assign_files_to_windows() {
1470        let files = [
1471            FileId::random(),
1472            FileId::random(),
1473            FileId::random(),
1474            FileId::random(),
1475        ];
1476        let windows = assign_to_windows(
1477            [
1478                new_file_handle_with_sequence(files[0], 0, 999, 0, 1),
1479                new_file_handle_with_sequence(files[1], 0, 999, 0, 1),
1480                new_file_handle_with_sequence(files[2], 0, 999, 0, 2),
1481                new_file_handle_with_sequence(files[3], 0, 999, 0, 2),
1482            ]
1483            .iter(),
1484            3,
1485        );
1486        assert_eq!(windows.len(), 1);
1487        let window_files = &windows.get(&0).unwrap().files;
1488        assert_eq!(4, window_files.len());
1489        assert_eq!(
1490            window_files
1491                .iter()
1492                .map(|f| f.file_id().file_id())
1493                .collect::<HashSet<_>>(),
1494            files.into_iter().collect()
1495        );
1496    }
1497
1498    #[test]
1499    fn test_assign_compacting_to_windows() {
1500        let files = [
1501            new_file_handle(FileId::random(), 0, 999, 0),
1502            new_file_handle(FileId::random(), 0, 999, 0),
1503            new_file_handle(FileId::random(), 0, 999, 0),
1504            new_file_handle(FileId::random(), 0, 999, 0),
1505            new_file_handle(FileId::random(), 0, 999, 0),
1506        ];
1507        files[0].set_compacting(true);
1508        files[2].set_compacting(true);
1509        let mut windows = assign_to_windows(files.iter(), 3);
1510        let window0 = windows.remove(&0).unwrap();
1511        assert_eq!(3, window0.files.len());
1512        let candidates = window0
1513            .files
1514            .iter()
1515            .map(|f| f.file_id().file_id())
1516            .collect::<HashSet<_>>();
1517        assert_eq!(candidates.len(), 3);
1518        assert_eq!(
1519            candidates,
1520            [
1521                files[1].file_id().file_id(),
1522                files[3].file_id().file_id(),
1523                files[4].file_id().file_id()
1524            ]
1525            .into_iter()
1526            .collect::<HashSet<_>>()
1527        );
1528    }
1529
1530    /// (Window value, overlapping, files' time ranges in window)
1531    type ExpectedWindowSpec = (i64, bool, Vec<(i64, i64)>);
1532
1533    fn pk_range(min: &'static [u8], max: &'static [u8]) -> Option<(Bytes, Bytes)> {
1534        Some((Bytes::from_static(min), Bytes::from_static(max)))
1535    }
1536
1537    fn check_assign_to_windows_with_overlapping(
1538        file_time_ranges: &[(i64, i64)],
1539        time_window: i64,
1540        expected_files: &[ExpectedWindowSpec],
1541    ) {
1542        let files: Vec<_> = (0..file_time_ranges.len())
1543            .map(|_| FileId::random())
1544            .collect();
1545
1546        let file_handles = files
1547            .iter()
1548            .zip(file_time_ranges.iter())
1549            .map(|(file_id, range)| new_file_handle(*file_id, range.0, range.1, 0))
1550            .collect::<Vec<_>>();
1551
1552        let windows = assign_to_windows(file_handles.iter(), time_window);
1553
1554        for (expected_window, overlapping, window_files) in expected_files {
1555            let actual_window = windows.get(expected_window).unwrap();
1556            let actual_overlapping = window_has_overlap(actual_window, &windows);
1557            assert_eq!(*overlapping, actual_overlapping);
1558            let mut file_ranges = actual_window
1559                .files
1560                .iter()
1561                .map(|f| {
1562                    let (s, e) = f.time_range();
1563                    (s.value(), e.value())
1564                })
1565                .collect::<Vec<_>>();
1566            file_ranges.sort_unstable_by(|l, r| l.0.cmp(&r.0).then(l.1.cmp(&r.1)));
1567            assert_eq!(window_files, &file_ranges);
1568        }
1569    }
1570
1571    #[test]
1572    fn test_assign_to_windows_with_overlapping() {
1573        check_assign_to_windows_with_overlapping(
1574            &[(0, 999), (1000, 1999), (2000, 2999)],
1575            2,
1576            &[
1577                (0, false, vec![(0, 999)]),
1578                (2, false, vec![(1000, 1999), (2000, 2999)]),
1579            ],
1580        );
1581
1582        check_assign_to_windows_with_overlapping(
1583            &[(0, 1), (0, 999), (100, 2999)],
1584            2,
1585            &[
1586                (0, true, vec![(0, 1), (0, 999)]),
1587                (2, true, vec![(100, 2999)]),
1588            ],
1589        );
1590
1591        check_assign_to_windows_with_overlapping(
1592            &[(0, 999), (1000, 1999), (2000, 2999), (3000, 3999)],
1593            2,
1594            &[
1595                (0, false, vec![(0, 999)]),
1596                (2, false, vec![(1000, 1999), (2000, 2999)]),
1597                (4, false, vec![(3000, 3999)]),
1598            ],
1599        );
1600
1601        check_assign_to_windows_with_overlapping(
1602            &[
1603                (0, 999),
1604                (1000, 1999),
1605                (2000, 2999),
1606                (3000, 3999),
1607                (0, 3999),
1608            ],
1609            2,
1610            &[
1611                (0, true, vec![(0, 999)]),
1612                (2, true, vec![(1000, 1999), (2000, 2999)]),
1613                (4, true, vec![(0, 3999), (3000, 3999)]),
1614            ],
1615        );
1616
1617        check_assign_to_windows_with_overlapping(
1618            &[
1619                (0, 999),
1620                (1000, 1999),
1621                (2000, 2999),
1622                (3000, 3999),
1623                (1999, 3999),
1624            ],
1625            2,
1626            &[
1627                (0, false, vec![(0, 999)]),
1628                (2, true, vec![(1000, 1999), (2000, 2999)]),
1629                (4, true, vec![(1999, 3999), (3000, 3999)]),
1630            ],
1631        );
1632
1633        check_assign_to_windows_with_overlapping(
1634            &[
1635                (0, 999),     // window 0
1636                (1000, 1999), // window 2
1637                (2000, 2999), // window 2
1638                (3000, 3999), // window 4
1639                (2999, 3999), // window 4
1640            ],
1641            2,
1642            &[
1643                // window 2 overlaps with window 4
1644                (0, false, vec![(0, 999)]),
1645                (2, true, vec![(1000, 1999), (2000, 2999)]),
1646                (4, true, vec![(2999, 3999), (3000, 3999)]),
1647            ],
1648        );
1649
1650        check_assign_to_windows_with_overlapping(
1651            &[
1652                (0, 999),     // window 0
1653                (1000, 1999), // window 2
1654                (2000, 2999), // window 2
1655                (3000, 3999), // window 4
1656                (0, 1000),    // // window 2
1657            ],
1658            2,
1659            &[
1660                // only window 0 overlaps with window 2.
1661                (0, true, vec![(0, 999)]),
1662                (2, true, vec![(0, 1000), (1000, 1999), (2000, 2999)]),
1663                (4, false, vec![(3000, 3999)]),
1664            ],
1665        );
1666    }
1667
1668    #[test]
1669    fn test_assign_to_windows_not_overlapping_when_pk_disjoint() {
1670        let files = [
1671            new_file_handle_with_size_sequence_and_primary_key_range(
1672                FileId::random(),
1673                0,
1674                1000,
1675                0,
1676                1,
1677                10,
1678                pk_range(b"a", b"f"),
1679            ),
1680            new_file_handle_with_size_sequence_and_primary_key_range(
1681                FileId::random(),
1682                500,
1683                1999,
1684                0,
1685                2,
1686                10,
1687                pk_range(b"x", b"z"),
1688            ),
1689        ];
1690
1691        let windows = assign_to_windows(files.iter(), 2);
1692
1693        let overlapping = window_has_overlap(windows.get(&2).unwrap(), &windows);
1694        assert!(!overlapping);
1695    }
1696
1697    #[test]
1698    fn test_assign_to_windows_pk_unknown_in_earlier_window_does_not_poison_later_windows() {
1699        let files = [
1700            new_file_handle(FileId::random(), 0, 1999, 0),
1701            new_file_handle_with_size_sequence_and_primary_key_range(
1702                FileId::random(),
1703                2000,
1704                3999,
1705                0,
1706                1,
1707                10,
1708                pk_range(b"a", b"f"),
1709            ),
1710            new_file_handle_with_size_sequence_and_primary_key_range(
1711                FileId::random(),
1712                3000,
1713                4999,
1714                0,
1715                2,
1716                10,
1717                pk_range(b"x", b"z"),
1718            ),
1719        ];
1720
1721        let windows = assign_to_windows(files.iter(), 2);
1722
1723        let overlapping = window_has_overlap(windows.get(&4).unwrap(), &windows);
1724        assert!(!overlapping);
1725    }
1726
1727    struct CompactionPickerTestCase {
1728        window_size: i64,
1729        input_files: Vec<FileHandle>,
1730        expected_outputs: Vec<ExpectedOutput>,
1731    }
1732
1733    impl CompactionPickerTestCase {
1734        async fn check(&self) {
1735            let file_id_to_idx = self
1736                .input_files
1737                .iter()
1738                .enumerate()
1739                .map(|(idx, file)| (file.file_id(), idx))
1740                .collect::<HashMap<_, _>>();
1741            let windows = assign_to_windows(self.input_files.iter(), self.window_size);
1742            let active_window =
1743                find_active_window_by_sequence(self.input_files.iter(), self.window_size).or_else(
1744                    || find_latest_window_in_seconds(self.input_files.iter(), self.window_size),
1745                );
1746            let output = TwcsPicker {
1747                trigger_file_num: 2,
1748                active_window_l1_merge_trigger: 8,
1749                inactive_window_trigger_file_num: 2,
1750                inactive_window_l1_merge_trigger: 2,
1751                time_window_seconds: None,
1752                max_output_file_size: None,
1753                append_mode: false,
1754                max_background_tasks: None,
1755                time_range: None,
1756            }
1757            .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
1758            .await
1759            .unwrap();
1760
1761            let output = output
1762                .iter()
1763                .map(|o| {
1764                    let input_file_ids = o
1765                        .inputs
1766                        .iter()
1767                        .map(|f| file_id_to_idx.get(&f.file_id()).copied().unwrap())
1768                        .collect::<HashSet<_>>();
1769                    (input_file_ids, o.output_level)
1770                })
1771                .collect::<Vec<_>>();
1772
1773            let expected = self
1774                .expected_outputs
1775                .iter()
1776                .map(|o| {
1777                    let input_file_ids = o.input_files.iter().copied().collect::<HashSet<_>>();
1778                    (input_file_ids, o.output_level)
1779                })
1780                .collect::<Vec<_>>();
1781            assert_eq!(expected, output);
1782        }
1783    }
1784
1785    struct ExpectedOutput {
1786        input_files: Vec<usize>,
1787        output_level: Level,
1788    }
1789
1790    #[tokio::test]
1791    async fn test_newer_windows_are_placed_last_for_execution() {
1792        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
1793
1794        // Case 1: 2 runs found in each time window.
1795        CompactionPickerTestCase {
1796            window_size: 3,
1797            input_files: [
1798                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
1799                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
1800                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3), //active windows
1801                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4), //active windows
1802            ]
1803            .to_vec(),
1804            expected_outputs: vec![
1805                ExpectedOutput {
1806                    input_files: vec![0, 1],
1807                    output_level: 1,
1808                },
1809                ExpectedOutput {
1810                    input_files: vec![2, 3],
1811                    output_level: 1,
1812                },
1813            ],
1814        }
1815        .check()
1816        .await;
1817
1818        // Case 2:
1819        //    -2000........-3
1820        // -3000.....-100
1821        //                    0..............2999
1822        //                      50..........2998
1823        //                     11.........2990
1824        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
1825        CompactionPickerTestCase {
1826            window_size: 3,
1827            input_files: [
1828                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
1829                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
1830                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3),
1831                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4),
1832                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 5),
1833            ]
1834            .to_vec(),
1835            expected_outputs: vec![
1836                ExpectedOutput {
1837                    input_files: vec![0, 1],
1838                    output_level: 1,
1839                },
1840                ExpectedOutput {
1841                    input_files: vec![2, 3, 4],
1842                    output_level: 1,
1843                },
1844            ],
1845        }
1846        .check()
1847        .await;
1848
1849        // Case 3:
1850        // A compaction may split output into several files that have overlapping time ranges and
1851        // the same sequence. They are ordinary compaction candidates now: the picker merges the
1852        // overlapping ones in the window that reaches the trigger.
1853        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
1854        CompactionPickerTestCase {
1855            window_size: 3,
1856            input_files: [
1857                new_file_handle_with_sequence(file_ids[0], 0, 2999, 1, 1),
1858                new_file_handle_with_sequence(file_ids[1], 0, 2998, 1, 1),
1859                new_file_handle_with_sequence(file_ids[2], 3000, 5999, 1, 2),
1860                new_file_handle_with_sequence(file_ids[3], 3000, 5000, 1, 2),
1861                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 3),
1862            ]
1863            .to_vec(),
1864            expected_outputs: vec![ExpectedOutput {
1865                input_files: vec![2, 3],
1866                output_level: 1,
1867            }],
1868        }
1869        .check()
1870        .await;
1871    }
1872
1873    #[tokio::test]
1874    async fn test_build_output_skips_pk_disjoint_files() {
1875        let files = [
1876            new_file_handle_with_size_sequence_and_primary_key_range(
1877                FileId::random(),
1878                0,
1879                2999,
1880                0,
1881                1,
1882                10,
1883                pk_range(b"a", b"f"),
1884            ),
1885            new_file_handle_with_size_sequence_and_primary_key_range(
1886                FileId::random(),
1887                50,
1888                2998,
1889                0,
1890                2,
1891                10,
1892                pk_range(b"x", b"z"),
1893            ),
1894        ];
1895        let windows = assign_to_windows(files.iter(), 3);
1896        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1897        let output = TwcsPicker {
1898            trigger_file_num: 4,
1899            active_window_l1_merge_trigger: 8,
1900            inactive_window_trigger_file_num: 4,
1901            inactive_window_l1_merge_trigger: 8,
1902            time_window_seconds: None,
1903            max_output_file_size: None,
1904            append_mode: false,
1905            max_background_tasks: None,
1906            time_range: None,
1907        }
1908        .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
1909        .await
1910        .unwrap();
1911
1912        assert!(output.is_empty());
1913    }
1914
1915    #[test]
1916    fn test_append_mode_filter_large_files() {
1917        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
1918        let max_output_file_size = 1000u64;
1919
1920        // Create files with different sizes
1921        let small_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 500);
1922        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 1500);
1923        let small_file_2 = new_file_handle_with_size_and_sequence(file_ids[2], 0, 999, 0, 3, 800);
1924        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[3], 0, 999, 0, 4, 2000);
1925
1926        let mut files_to_merge = vec![small_file_1, large_file_1, small_file_2, large_file_2];
1927
1928        // Test filtering logic directly
1929        let original_count = files_to_merge.len();
1930
1931        // Apply append mode filtering
1932        files_to_merge.retain(|file| file.size() <= max_output_file_size);
1933
1934        // Should have filtered out 2 large files, leaving 2 small files
1935        assert_eq!(files_to_merge.len(), 2);
1936        assert_eq!(original_count, 4);
1937
1938        // Verify the remaining files are the small ones
1939        for file in &files_to_merge {
1940            assert!(
1941                file.size() <= max_output_file_size,
1942                "File size {} should be <= {}",
1943                file.size(),
1944                max_output_file_size
1945            );
1946        }
1947    }
1948
1949    #[tokio::test]
1950    async fn test_build_output_multiple_windows_with_zero_runs() {
1951        let file_ids = (0..7).map(|_| FileId::random()).collect::<Vec<_>>();
1952
1953        let files = [
1954            // Window 0: Contains 3 files but not forming any runs (not enough files in sequence to reach trigger_file_num)
1955            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
1956            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
1957            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
1958            // Window 3: Contains files that will form 2 runs
1959            new_file_handle_with_sequence(file_ids[3], 3000, 3999, 0, 4),
1960            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
1961            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
1962            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
1963        ];
1964
1965        let windows = assign_to_windows(files.iter(), 3);
1966
1967        // Create picker with trigger_file_num of 4 so single files won't form runs in first window
1968        let picker = TwcsPicker {
1969            trigger_file_num: 4, // High enough to prevent runs in first window
1970            active_window_l1_merge_trigger: 8,
1971            inactive_window_trigger_file_num: 4,
1972            inactive_window_l1_merge_trigger: 8,
1973            time_window_seconds: Some(3),
1974            max_output_file_size: None,
1975            append_mode: false,
1976            max_background_tasks: None,
1977            time_range: None,
1978        };
1979
1980        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1981        let output = picker
1982            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
1983            .await
1984            .unwrap();
1985
1986        assert!(
1987            !output.is_empty(),
1988            "Should have output from windows with runs, even when one window has 0 runs"
1989        );
1990
1991        let all_output_files: Vec<_> = output
1992            .iter()
1993            .flat_map(|o| o.inputs.iter())
1994            .map(|f| f.file_id().file_id())
1995            .collect();
1996
1997        assert!(
1998            all_output_files.contains(&file_ids[3])
1999                || all_output_files.contains(&file_ids[4])
2000                || all_output_files.contains(&file_ids[5]),
2001            "Output should contain files from the window with runs"
2002        );
2003    }
2004
2005    #[tokio::test]
2006    async fn test_build_output_single_window_zero_runs() {
2007        let file_ids = (0..2).map(|_| FileId::random()).collect::<Vec<_>>();
2008
2009        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 2000); // 2000 bytes
2010        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 2500); // 2500 bytes
2011
2012        let files = [large_file_1, large_file_2];
2013
2014        let windows = assign_to_windows(files.iter(), 3);
2015
2016        let picker = TwcsPicker {
2017            trigger_file_num: 2,
2018            active_window_l1_merge_trigger: 8,
2019            inactive_window_trigger_file_num: 2,
2020            inactive_window_l1_merge_trigger: 8,
2021            time_window_seconds: Some(3),
2022            max_output_file_size: Some(1000),
2023            append_mode: true,
2024            max_background_tasks: None,
2025            time_range: None,
2026        };
2027
2028        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2029        let output = picker
2030            .build_output_with_time_range(RegionId::from_u64(456), windows, active_window, None)
2031            .await
2032            .unwrap();
2033
2034        // Should return empty output (no compaction needed)
2035        assert!(
2036            output.is_empty(),
2037            "Should return empty output when no runs are found after filtering"
2038        );
2039    }
2040
2041    #[tokio::test]
2042    async fn test_append_mode_can_pick_remaining_single_level_files() {
2043        let files = [
2044            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 100),
2045            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 100),
2046            new_file_handle_with_size_and_sequence(FileId::random(), 40, 49, 0, 3, 100),
2047            new_file_handle_with_size_and_sequence(FileId::random(), 60, 69, 0, 4, 2_000),
2048        ];
2049        let windows = assign_to_windows(files.iter(), 1);
2050        let picker = TwcsPicker {
2051            trigger_file_num: 4,
2052            active_window_l1_merge_trigger: 8,
2053            inactive_window_trigger_file_num: 4,
2054            inactive_window_l1_merge_trigger: 8,
2055            time_window_seconds: Some(1),
2056            max_output_file_size: Some(1_000),
2057            append_mode: true,
2058            max_background_tasks: None,
2059            time_range: None,
2060        };
2061
2062        let output = picker
2063            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
2064            .await
2065            .unwrap();
2066
2067        assert_eq!(1, output.len());
2068        assert_eq!(3, output[0].inputs.len());
2069    }
2070
2071    #[tokio::test]
2072    async fn test_max_background_tasks_truncation() {
2073        let file_ids = (0..10).map(|_| FileId::random()).collect::<Vec<_>>();
2074        let max_background_tasks = 3;
2075
2076        // Create files across multiple windows that will generate multiple compaction outputs
2077        let files = [
2078            // Window 0: 4 files that will form a run
2079            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2080            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2081            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2082            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
2083            // Window 3: 4 files that will form another run
2084            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
2085            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
2086            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
2087            new_file_handle_with_sequence(file_ids[7], 3000, 3999, 0, 8),
2088            // Window 6: 4 files that will form another run
2089            new_file_handle_with_sequence(file_ids[8], 6000, 6999, 0, 9),
2090            new_file_handle_with_sequence(file_ids[9], 6000, 6999, 0, 10),
2091        ];
2092
2093        let windows = assign_to_windows(files.iter(), 3);
2094
2095        let picker = TwcsPicker {
2096            trigger_file_num: 4,
2097            active_window_l1_merge_trigger: 8,
2098            inactive_window_trigger_file_num: 4,
2099            inactive_window_l1_merge_trigger: 8,
2100            time_window_seconds: Some(3),
2101            max_output_file_size: None,
2102            append_mode: false,
2103            max_background_tasks: Some(max_background_tasks),
2104            time_range: None,
2105        };
2106
2107        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2108        let output = picker
2109            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2110            .await
2111            .unwrap();
2112
2113        // Should have at most max_background_tasks outputs
2114        assert!(
2115            output.len() <= max_background_tasks,
2116            "Output should be truncated to max_background_tasks: expected <= {}, got {}",
2117            max_background_tasks,
2118            output.len()
2119        );
2120
2121        // Without max_background_tasks, should have more outputs
2122        let picker_no_limit = TwcsPicker {
2123            trigger_file_num: 4,
2124            active_window_l1_merge_trigger: 8,
2125            inactive_window_trigger_file_num: 4,
2126            inactive_window_l1_merge_trigger: 8,
2127            time_window_seconds: Some(3),
2128            max_output_file_size: None,
2129            append_mode: false,
2130            max_background_tasks: None,
2131            time_range: None,
2132        };
2133
2134        let windows_no_limit = assign_to_windows(files.iter(), 3);
2135        let output_no_limit = picker_no_limit
2136            .build_output_with_time_range(
2137                RegionId::from_u64(123),
2138                windows_no_limit,
2139                active_window,
2140                None,
2141            )
2142            .await
2143            .unwrap();
2144
2145        // Without limit, should have more outputs (if there are enough windows)
2146        if output_no_limit.len() > max_background_tasks {
2147            assert!(
2148                output_no_limit.len() > output.len(),
2149                "Without limit should have more outputs than with limit"
2150            );
2151        }
2152    }
2153
2154    #[tokio::test]
2155    async fn test_max_background_tasks_no_truncation_when_under_limit() {
2156        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2157        let max_background_tasks = 10; // Larger than expected outputs
2158
2159        // Create files in one window that will generate one compaction output
2160        let files = [
2161            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2162            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2163            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2164            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
2165        ];
2166
2167        let windows = assign_to_windows(files.iter(), 3);
2168
2169        let picker = TwcsPicker {
2170            trigger_file_num: 4,
2171            active_window_l1_merge_trigger: 8,
2172            inactive_window_trigger_file_num: 4,
2173            inactive_window_l1_merge_trigger: 8,
2174            time_window_seconds: Some(3),
2175            max_output_file_size: None,
2176            append_mode: false,
2177            max_background_tasks: Some(max_background_tasks),
2178            time_range: None,
2179        };
2180
2181        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2182        let output = picker
2183            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2184            .await
2185            .unwrap();
2186
2187        // Should have all outputs since we're under the limit
2188        assert!(
2189            output.len() <= max_background_tasks,
2190            "Output should be within limit"
2191        );
2192        // Should have at least one output
2193        assert!(!output.is_empty(), "Should have at least one output");
2194    }
2195
2196    #[tokio::test]
2197    async fn test_pick_multiple_runs() {
2198        common_telemetry::init_default_ut_logging();
2199
2200        let num_files = 8;
2201        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
2202
2203        // Create files with different sequences so they form multiple runs
2204        let files: Vec<_> = file_ids
2205            .iter()
2206            .enumerate()
2207            .map(|(idx, file_id)| {
2208                new_file_handle_with_size_and_sequence(
2209                    *file_id,
2210                    0,
2211                    999,
2212                    0,
2213                    (idx + 1) as u64,
2214                    1024 * 1024,
2215                )
2216            })
2217            .collect();
2218
2219        let windows = assign_to_windows(files.iter(), 3);
2220
2221        let picker = TwcsPicker {
2222            trigger_file_num: 4,
2223            active_window_l1_merge_trigger: 8,
2224            inactive_window_trigger_file_num: 4,
2225            inactive_window_l1_merge_trigger: 8,
2226            time_window_seconds: Some(3),
2227            max_output_file_size: None,
2228            append_mode: false,
2229            max_background_tasks: None,
2230            time_range: None,
2231        };
2232
2233        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2234        let output = picker
2235            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2236            .await
2237            .unwrap();
2238
2239        assert_eq!(1, output.len());
2240        assert_eq!(output[0].inputs.len(), num_files);
2241    }
2242
2243    #[tokio::test]
2244    async fn test_window_trigger_can_exceed_input_limit() {
2245        common_telemetry::init_default_ut_logging();
2246
2247        let num_files = 50;
2248        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
2249
2250        // Create files with different sequences so they form 2 runs
2251        let files: Vec<_> = file_ids
2252            .iter()
2253            .enumerate()
2254            .map(|(idx, file_id)| {
2255                new_file_handle_with_size_and_sequence(
2256                    *file_id,
2257                    (idx / 2 * 10) as i64,
2258                    (idx / 2 * 10 + 5) as i64,
2259                    0,
2260                    (idx + 1) as u64,
2261                    1024 * 1024,
2262                )
2263            })
2264            .collect();
2265
2266        let windows = assign_to_windows(files.iter(), 3);
2267
2268        let picker = TwcsPicker {
2269            trigger_file_num: num_files,
2270            active_window_l1_merge_trigger: 8,
2271            inactive_window_trigger_file_num: num_files,
2272            inactive_window_l1_merge_trigger: 8,
2273            time_window_seconds: Some(3),
2274            max_output_file_size: None,
2275            append_mode: false,
2276            max_background_tasks: None,
2277            time_range: None,
2278        };
2279
2280        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2281        let output = picker
2282            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2283            .await
2284            .unwrap();
2285
2286        assert_eq!(1, output.len());
2287        assert_eq!(output[0].inputs.len(), num_files.min(*MAX_INPUT_FILES));
2288    }
2289
2290    #[tokio::test]
2291    async fn test_limit_max_input_files_keeps_deletion_markers() {
2292        common_telemetry::init_default_ut_logging();
2293
2294        // One large file group spanning the whole window plus enough small ones to fill the
2295        // input limit. That is exactly 2 runs, so the window allows filtering deletions.
2296        let mut files = vec![new_file_handle_with_size_and_sequence(
2297            FileId::random(),
2298            0,
2299            3_000_000,
2300            0,
2301            1,
2302            1024 * 1024 * 1024,
2303        )];
2304        files.extend((0..DEFAULT_MAX_INPUT_FILES as i64).map(|idx| {
2305            new_file_handle_with_size_and_sequence(
2306                FileId::random(),
2307                (idx + 1) * 10_000,
2308                (idx + 1) * 10_000 + 1_000,
2309                0,
2310                (idx + 2) as u64,
2311                1024,
2312            )
2313        }));
2314
2315        let windows = assign_to_windows(files.iter(), 3600);
2316
2317        let picker = TwcsPicker {
2318            trigger_file_num: 4,
2319            active_window_l1_merge_trigger: 8,
2320            inactive_window_trigger_file_num: 4,
2321            inactive_window_l1_merge_trigger: 8,
2322            time_window_seconds: Some(3600),
2323            max_output_file_size: None,
2324            append_mode: false,
2325            max_background_tasks: None,
2326            time_range: None,
2327        };
2328
2329        let active_window = find_latest_window_in_seconds(files.iter(), 3600);
2330        let output = picker
2331            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2332            .await
2333            .unwrap();
2334
2335        assert_eq!(1, output.len());
2336        // The input file num limit picks the smallest groups first, so the large group is
2337        // left behind while the small groups that overlap it are compacted.
2338        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2339        assert!(
2340            !output[0].filter_deleted,
2341            "deletion markers must be kept once the file num limit drops files they may mask"
2342        );
2343    }
2344
2345    #[tokio::test]
2346    async fn test_limit_max_input_files_still_filters_without_overlap() {
2347        common_telemetry::init_default_ut_logging();
2348
2349        // More disjoint file groups than the input limit, i.e. a single run. Nothing left
2350        // behind can hold a row masked by a deletion marker we compact.
2351        let files: Vec<_> = (0..DEFAULT_MAX_INPUT_FILES as i64 + 8)
2352            .map(|idx| {
2353                new_file_handle_with_size_and_sequence(
2354                    FileId::random(),
2355                    (idx + 1) * 10_000,
2356                    (idx + 1) * 10_000 + 1_000,
2357                    0,
2358                    (idx + 1) as u64,
2359                    1024,
2360                )
2361            })
2362            .collect();
2363
2364        let windows = assign_to_windows(files.iter(), 3600);
2365
2366        let picker = TwcsPicker {
2367            trigger_file_num: 4,
2368            active_window_l1_merge_trigger: 8,
2369            inactive_window_trigger_file_num: 4,
2370            inactive_window_l1_merge_trigger: 8,
2371            time_window_seconds: Some(3600),
2372            max_output_file_size: Some(1024 * 1024 * 1024),
2373            append_mode: false,
2374            max_background_tasks: None,
2375            time_range: None,
2376        };
2377
2378        let active_window = find_latest_window_in_seconds(files.iter(), 3600);
2379        let output = picker
2380            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2381            .await
2382            .unwrap();
2383
2384        assert_eq!(1, output.len());
2385        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2386        assert!(output[0].filter_deleted);
2387    }
2388
2389    #[tokio::test]
2390    async fn test_newer_windows_have_priority() {
2391        let older_file_ids = [FileId::random(), FileId::random()];
2392        let newer_file_ids = [FileId::random(), FileId::random()];
2393        let files = [
2394            new_file_handle_with_sequence(older_file_ids[0], 1_000, 1_999, 0, 1),
2395            new_file_handle_with_sequence(older_file_ids[1], 1_000, 1_999, 0, 2),
2396            new_file_handle_with_sequence(newer_file_ids[0], 7_000, 7_999, 0, 3),
2397            new_file_handle_with_sequence(newer_file_ids[1], 7_000, 7_999, 0, 4),
2398        ];
2399        let windows = assign_to_windows(files.iter(), 3);
2400        let picker = TwcsPicker {
2401            trigger_file_num: 2,
2402            active_window_l1_merge_trigger: 8,
2403            inactive_window_trigger_file_num: 2,
2404            inactive_window_l1_merge_trigger: 8,
2405            time_window_seconds: Some(3),
2406            max_output_file_size: None,
2407            append_mode: false,
2408            max_background_tasks: Some(1),
2409            time_range: None,
2410        };
2411
2412        let output = picker
2413            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(9), None)
2414            .await
2415            .unwrap();
2416
2417        assert_eq!(1, output.len());
2418        assert_eq!(
2419            newer_file_ids.into_iter().collect::<HashSet<_>>(),
2420            output[0]
2421                .inputs
2422                .iter()
2423                .map(|file| file.file_id().file_id())
2424                .collect::<HashSet<_>>()
2425        );
2426    }
2427
2428    #[test]
2429    fn test_filter_time_windows_by_time_range() {
2430        let time_range = TimestampRange::new(
2431            Timestamp::new_millisecond(1_200),
2432            Timestamp::new_millisecond(1_800),
2433        )
2434        .unwrap();
2435
2436        assert!(time_window_intersects_range(3, 3, &time_range));
2437        assert!(!time_window_intersects_range(9, 3, &time_range));
2438
2439        let boundary_range =
2440            TimestampRange::new(Timestamp::new_second(0), Timestamp::new_second(3)).unwrap();
2441        assert!(time_window_intersects_range(0, 3, &boundary_range));
2442        assert!(time_window_intersects_range(3, 3, &boundary_range));
2443        assert!(!time_window_intersects_range(6, 3, &boundary_range));
2444
2445        let overflowing_range = TimestampRange::new(
2446            Timestamp::new_second(i64::MAX - 1),
2447            Timestamp::new_second(i64::MAX),
2448        )
2449        .unwrap();
2450        assert!(!time_window_intersects_range(0, 4, &overflowing_range));
2451    }
2452
2453    #[tokio::test]
2454    async fn test_time_range_filter_precedes_background_task_limit() {
2455        let early_file_ids = [FileId::random(), FileId::random()];
2456        let selected_file_ids = [FileId::random(), FileId::random()];
2457        let files = [
2458            new_file_handle_with_sequence(early_file_ids[0], 1_000, 1_999, 0, 1),
2459            new_file_handle_with_sequence(early_file_ids[1], 1_000, 1_999, 0, 2),
2460            new_file_handle_with_sequence(selected_file_ids[0], 7_000, 7_999, 0, 3),
2461            new_file_handle_with_sequence(selected_file_ids[1], 7_000, 7_999, 0, 4),
2462        ];
2463        let windows = assign_to_windows(files.iter(), 3);
2464        let picker = TwcsPicker {
2465            trigger_file_num: 2,
2466            active_window_l1_merge_trigger: 8,
2467            inactive_window_trigger_file_num: 2,
2468            inactive_window_l1_merge_trigger: 8,
2469            time_window_seconds: Some(3),
2470            max_output_file_size: None,
2471            append_mode: false,
2472            max_background_tasks: Some(1),
2473            time_range: TimestampRange::new(
2474                Timestamp::new_millisecond(7_200),
2475                Timestamp::new_millisecond(7_800),
2476            ),
2477        };
2478
2479        let output = picker
2480            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(9), Some(3))
2481            .await
2482            .unwrap();
2483
2484        assert_eq!(1, output.len());
2485        assert_eq!(
2486            selected_file_ids.into_iter().collect::<HashSet<_>>(),
2487            output[0]
2488                .inputs
2489                .iter()
2490                .map(|file| file.file_id().file_id())
2491                .collect::<HashSet<_>>()
2492        );
2493    }
2494
2495    #[tokio::test]
2496    async fn test_inactive_window_uses_its_trigger_file_num() {
2497        let files = [
2498            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2499            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
2500        ];
2501        let windows = assign_to_windows(files.iter(), 100);
2502        let picker = TwcsPicker {
2503            trigger_file_num: 4,
2504            active_window_l1_merge_trigger: 8,
2505            inactive_window_trigger_file_num: 2,
2506            inactive_window_l1_merge_trigger: 8,
2507            time_window_seconds: Some(100),
2508            max_output_file_size: None,
2509            append_mode: false,
2510            max_background_tasks: None,
2511            time_range: None,
2512        };
2513
2514        let output = picker
2515            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
2516            .await
2517            .unwrap();
2518
2519        assert_eq!(1, output.len());
2520        assert_eq!(2, output[0].inputs.len());
2521    }
2522
2523    #[test]
2524    fn test_inactive_window_l1_trigger_is_independent_of_l0_trigger() {
2525        let l0_files = (0..6)
2526            .map(|idx| {
2527                new_file_handle_with_size_and_sequence(
2528                    FileId::random(),
2529                    idx * 20,
2530                    idx * 20 + 10,
2531                    0,
2532                    idx as u64 + 1,
2533                    10,
2534                )
2535            })
2536            .collect();
2537        let l1_files = (0..2)
2538            .map(|idx| {
2539                new_file_handle_with_size_and_sequence(
2540                    FileId::random(),
2541                    idx * 20,
2542                    idx * 20 + 10,
2543                    1,
2544                    idx as u64 + 10,
2545                    10,
2546                )
2547            })
2548            .collect();
2549
2550        let (inputs, _) = pick_inactive_window_files(
2551            l0_files,
2552            l1_files,
2553            InactiveWindowPick {
2554                l0_file_num: 8,
2555                l1_file_num: 2,
2556                phase: PickPhase::L1FileReduction,
2557            },
2558            None,
2559        );
2560
2561        assert_eq!(2, inputs.len());
2562        assert!(inputs.iter().all(|file| file.level() == 1));
2563    }
2564
2565    #[test]
2566    fn test_inactive_window_does_not_fallback_to_l1_below_trigger() {
2567        let l1_files = (0..2)
2568            .map(|idx| {
2569                new_file_handle_with_size_and_sequence(
2570                    FileId::random(),
2571                    idx * 20,
2572                    idx * 20 + 10,
2573                    1,
2574                    idx as u64 + 1,
2575                    10,
2576                )
2577            })
2578            .collect();
2579
2580        let (inputs, _) = pick_inactive_window_files(
2581            vec![],
2582            l1_files,
2583            InactiveWindowPick {
2584                l0_file_num: 2,
2585                l1_file_num: 8,
2586                phase: PickPhase::L1FileReduction,
2587            },
2588            None,
2589        );
2590
2591        assert!(inputs.is_empty());
2592    }
2593
2594    #[tokio::test]
2595    async fn test_inactive_window_requires_a_level_trigger_before_mixed_fallback() {
2596        let files = [
2597            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2598            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 1, 2, 10),
2599        ];
2600        let windows = assign_to_windows(files.iter(), 100);
2601        let picker = TwcsPicker {
2602            trigger_file_num: 4,
2603            active_window_l1_merge_trigger: 8,
2604            inactive_window_trigger_file_num: 8,
2605            inactive_window_l1_merge_trigger: 2,
2606            time_window_seconds: Some(100),
2607            max_output_file_size: Some(1_000),
2608            append_mode: false,
2609            max_background_tasks: None,
2610            time_range: None,
2611        };
2612
2613        let output = picker
2614            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
2615            .await
2616            .unwrap();
2617
2618        assert!(output.is_empty());
2619    }
2620
2621    #[tokio::test]
2622    async fn test_count_first_prefers_more_files_over_smaller_overlap() {
2623        let files = [
2624            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2625            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
2626            new_file_handle_with_size_and_sequence(FileId::random(), 40, 50, 0, 3, 10),
2627            new_file_handle_with_size_and_sequence(FileId::random(), 5, 15, 0, 4, 10),
2628        ];
2629        let windows = assign_to_windows(files.iter(), 100);
2630        let picker = TwcsPicker {
2631            trigger_file_num: 2,
2632            active_window_l1_merge_trigger: 8,
2633            inactive_window_trigger_file_num: 2,
2634            inactive_window_l1_merge_trigger: 8,
2635            time_window_seconds: Some(100),
2636            max_output_file_size: None,
2637            append_mode: false,
2638            max_background_tasks: None,
2639            time_range: None,
2640        };
2641
2642        let output = picker
2643            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
2644            .await
2645            .unwrap();
2646
2647        assert_eq!(1, output.len());
2648        assert_eq!(4, output[0].inputs.len());
2649    }
2650
2651    #[tokio::test]
2652    async fn test_count_first_trigger_counts_physical_ssts() {
2653        let files = [
2654            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2655            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2656            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
2657        ];
2658        let windows = assign_to_windows(files.iter(), 100);
2659        let picker = TwcsPicker {
2660            trigger_file_num: 3,
2661            active_window_l1_merge_trigger: 8,
2662            inactive_window_trigger_file_num: 3,
2663            inactive_window_l1_merge_trigger: 8,
2664            time_window_seconds: Some(100),
2665            max_output_file_size: None,
2666            append_mode: false,
2667            max_background_tasks: None,
2668            time_range: None,
2669        };
2670
2671        let output = picker
2672            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
2673            .await
2674            .unwrap();
2675
2676        assert_eq!(1, output.len());
2677        assert_eq!(3, output[0].inputs.len());
2678    }
2679
2680    #[tokio::test]
2681    async fn test_count_first_does_not_compact_overlap_below_trigger() {
2682        let files = [
2683            new_file_handle_with_size_and_sequence(FileId::random(), 0, 20, 0, 1, 10),
2684            new_file_handle_with_size_and_sequence(FileId::random(), 10, 30, 0, 2, 10),
2685        ];
2686        let windows = assign_to_windows(files.iter(), 100);
2687        let picker = TwcsPicker {
2688            trigger_file_num: 3,
2689            active_window_l1_merge_trigger: 8,
2690            inactive_window_trigger_file_num: 3,
2691            inactive_window_l1_merge_trigger: 8,
2692            time_window_seconds: Some(100),
2693            max_output_file_size: None,
2694            append_mode: false,
2695            max_background_tasks: None,
2696            time_range: None,
2697        };
2698
2699        let output = picker
2700            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
2701            .await
2702            .unwrap();
2703
2704        assert!(output.is_empty());
2705    }
2706
2707    #[tokio::test]
2708    async fn test_filter_deleted_is_false_when_selected_files_overlap_unselected_file() {
2709        let mut files = (0..32)
2710            .map(|idx| {
2711                new_file_handle_with_size_and_sequence(
2712                    FileId::random(),
2713                    idx * 10,
2714                    idx * 10 + 9,
2715                    0,
2716                    idx as u64 + 1,
2717                    10,
2718                )
2719            })
2720            .collect::<Vec<_>>();
2721        files.push(new_file_handle_with_size_and_sequence(
2722            FileId::random(),
2723            0,
2724            320,
2725            0,
2726            33,
2727            10,
2728        ));
2729        let windows = assign_to_windows(files.iter(), 1000);
2730        let picker = TwcsPicker {
2731            trigger_file_num: 2,
2732            active_window_l1_merge_trigger: 8,
2733            inactive_window_trigger_file_num: 2,
2734            inactive_window_l1_merge_trigger: 8,
2735            time_window_seconds: Some(1000),
2736            max_output_file_size: None,
2737            append_mode: false,
2738            max_background_tasks: None,
2739            time_range: None,
2740        };
2741
2742        let output = picker
2743            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
2744            .await
2745            .unwrap();
2746
2747        assert_eq!(1, output.len());
2748        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2749        assert!(!output[0].filter_deleted);
2750    }
2751
2752    fn new_file(start: i64, end: i64, sequence: u64, file_size: u64) -> FileHandle {
2753        new_file_handle_with_size_and_sequence(FileId::random(), start, end, 0, sequence, file_size)
2754    }
2755
2756    fn new_file_with_level_and_rows(
2757        start: i64,
2758        end: i64,
2759        level: Level,
2760        sequence: u64,
2761        file_size: u64,
2762        num_rows: u64,
2763    ) -> FileHandle {
2764        let file = new_file_handle_with_size_and_sequence(
2765            FileId::random(),
2766            start,
2767            end,
2768            level,
2769            sequence,
2770            file_size,
2771        );
2772        let mut meta = file.meta_ref().clone();
2773        meta.num_rows = num_rows;
2774        FileHandle::new(meta, crate::test_util::new_noop_file_purger())
2775    }
2776
2777    fn picked_ranges(files: &[FileHandle]) -> Vec<(i64, i64)> {
2778        files
2779            .iter()
2780            .map(|file| {
2781                let (start, end) = file.range();
2782                (start.value(), end.value())
2783            })
2784            .collect()
2785    }
2786
2787    #[test]
2788    fn test_count_first_rejects_dominant_historical_file() {
2789        let picked = pick_count_first(
2790            vec![SortedRun::from(vec![
2791                new_file(0, 9, 1, 400),
2792                new_file(20, 29, 2, 100),
2793            ])],
2794            None,
2795        );
2796
2797        assert!(picked.is_empty());
2798    }
2799
2800    #[test]
2801    fn test_count_first_accepts_balanced_historical_file() {
2802        let picked = pick_count_first(
2803            vec![SortedRun::from(vec![
2804                new_file(0, 9, 1, 400),
2805                new_file(20, 29, 2, 100),
2806                new_file(40, 49, 3, 100),
2807                new_file(60, 69, 4, 100),
2808                new_file(80, 89, 5, 100),
2809            ])],
2810            None,
2811        );
2812
2813        assert_eq!(
2814            vec![(0, 9), (20, 29), (40, 49), (60, 69), (80, 89)],
2815            picked_ranges(&picked)
2816        );
2817    }
2818
2819    #[test]
2820    fn test_count_first_finds_smaller_balanced_interval_when_larger_one_is_unbalanced() {
2821        let picked = pick_count_first(
2822            vec![SortedRun::from(vec![
2823                new_file(0, 9, 1, 1000),
2824                new_file(20, 29, 2, 100),
2825                new_file(40, 49, 3, 100),
2826                new_file(60, 69, 4, 100),
2827                new_file(80, 89, 5, 100),
2828            ])],
2829            None,
2830        );
2831
2832        assert_eq!(
2833            vec![(20, 29), (40, 49), (60, 69), (80, 89)],
2834            picked_ranges(&picked)
2835        );
2836    }
2837
2838    #[test]
2839    fn test_count_first_prefers_overlap_participants_when_file_counts_match() {
2840        let first_run = (0..DEFAULT_MAX_INPUT_FILES)
2841            .map(|idx| {
2842                let start = idx as i64 * 20;
2843                let end = if idx + 1 == DEFAULT_MAX_INPUT_FILES {
2844                    700
2845                } else {
2846                    start + 9
2847                };
2848                new_file(start, end, idx as u64 + 1, 10)
2849            })
2850            .collect::<Vec<_>>();
2851        let overlapping = new_file(690, 710, 100, 10);
2852
2853        let picked = pick_count_first(
2854            vec![
2855                SortedRun::from(first_run),
2856                SortedRun::from(vec![overlapping]),
2857            ],
2858            None,
2859        );
2860        let ranges = picked_ranges(&picked);
2861
2862        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
2863        assert!(!ranges.contains(&(0, 9)));
2864        assert!(ranges.contains(&(690, 710)));
2865    }
2866
2867    #[tokio::test]
2868    async fn test_picker_avoids_chained_l1_rewrites_by_compacting_levels_separately() {
2869        let mut enough_l0 = (0..DEFAULT_MAX_INPUT_FILES)
2870            .map(|idx| {
2871                let start = idx as i64 * 20;
2872                new_file_handle_with_size_and_sequence(
2873                    FileId::random(),
2874                    start,
2875                    start + 9,
2876                    0,
2877                    idx as u64 + 1,
2878                    10,
2879                )
2880            })
2881            .collect::<Vec<_>>();
2882        enough_l0.push(new_file_handle_with_size_and_sequence(
2883            FileId::random(),
2884            0,
2885            700,
2886            1,
2887            100,
2888            100,
2889        ));
2890        let mut enough_l1 = (0..4)
2891            .map(|idx| {
2892                new_file_handle_with_size_and_sequence(
2893                    FileId::random(),
2894                    idx * 100,
2895                    idx * 100 + 99,
2896                    1,
2897                    idx as u64 + 1,
2898                    100,
2899                )
2900            })
2901            .collect::<Vec<_>>();
2902        enough_l1.extend((0..3).map(|idx| {
2903            new_file_handle_with_size_and_sequence(
2904                FileId::random(),
2905                idx * 20,
2906                idx * 20 + 9,
2907                0,
2908                idx as u64 + 10,
2909                10,
2910            )
2911        }));
2912        let picker = TwcsPicker {
2913            trigger_file_num: 4,
2914            active_window_l1_merge_trigger: 8,
2915            inactive_window_trigger_file_num: 4,
2916            inactive_window_l1_merge_trigger: 4,
2917            time_window_seconds: Some(1),
2918            max_output_file_size: None,
2919            append_mode: false,
2920            max_background_tasks: None,
2921            time_range: None,
2922        };
2923
2924        for (case, files, expected_level, expected_len) in [
2925            ("L0 reaches trigger", enough_l0, 0, DEFAULT_MAX_INPUT_FILES),
2926            ("L0 fallback precedes triggered L1", enough_l1, 0, 3),
2927        ] {
2928            let windows = assign_to_windows(files.iter(), 1);
2929            let output = picker
2930                .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
2931                .await
2932                .unwrap();
2933
2934            assert_eq!(1, output.len(), "{case}");
2935            assert_eq!(expected_len, output[0].inputs.len(), "{case}");
2936            assert!(
2937                output[0]
2938                    .inputs
2939                    .iter()
2940                    .all(|file| file.level() == expected_level),
2941                "{case}"
2942            );
2943        }
2944    }
2945
2946    #[tokio::test]
2947    async fn test_active_window_defers_l1_below_safety_trigger() {
2948        let files = (0..7)
2949            .map(|idx| {
2950                let start = idx * 100;
2951                new_file_handle_with_size_and_sequence(
2952                    FileId::random(),
2953                    start,
2954                    start + 99,
2955                    1,
2956                    idx as u64 + 1,
2957                    100,
2958                )
2959            })
2960            .collect::<Vec<_>>();
2961        let windows = assign_to_windows(files.iter(), 1);
2962        let active_window = windows.keys().next().copied();
2963        let picker = TwcsPicker {
2964            trigger_file_num: 4,
2965            active_window_l1_merge_trigger: 8,
2966            inactive_window_trigger_file_num: 2,
2967            inactive_window_l1_merge_trigger: 8,
2968            time_window_seconds: Some(1),
2969            max_output_file_size: None,
2970            append_mode: false,
2971            max_background_tasks: None,
2972            time_range: None,
2973        };
2974
2975        let output = picker
2976            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
2977            .await
2978            .unwrap();
2979
2980        assert!(output.is_empty());
2981    }
2982
2983    #[tokio::test]
2984    async fn test_active_window_l1_safety_trigger_is_independent_of_l0_trigger() {
2985        let files = (0..8)
2986            .map(|idx| {
2987                let start = idx * 100;
2988                new_file_handle_with_size_and_sequence(
2989                    FileId::random(),
2990                    start,
2991                    start + 99,
2992                    1,
2993                    idx as u64 + 1,
2994                    100,
2995                )
2996            })
2997            .collect::<Vec<_>>();
2998        let windows = assign_to_windows(files.iter(), 1);
2999        let active_window = windows.keys().next().copied();
3000        let picker = TwcsPicker {
3001            trigger_file_num: 16,
3002            active_window_l1_merge_trigger: 8,
3003            inactive_window_trigger_file_num: 2,
3004            inactive_window_l1_merge_trigger: 8,
3005            time_window_seconds: Some(1),
3006            max_output_file_size: None,
3007            append_mode: false,
3008            max_background_tasks: None,
3009            time_range: None,
3010        };
3011
3012        let output = picker
3013            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3014            .await
3015            .unwrap();
3016
3017        assert_eq!(1, output.len());
3018        assert_eq!(8, output[0].inputs.len());
3019        assert!(output[0].inputs.iter().all(|file| file.level() == 1));
3020    }
3021
3022    #[tokio::test]
3023    async fn test_picker_falls_back_to_l1_when_triggered_l0_cannot_make_progress() {
3024        let mut files = (0..4)
3025            .map(|idx| {
3026                let start = idx * 20;
3027                new_file_handle_with_size_and_sequence(
3028                    FileId::random(),
3029                    start,
3030                    start + 9,
3031                    0,
3032                    idx as u64 + 1,
3033                    600,
3034                )
3035            })
3036            .collect::<Vec<_>>();
3037        files.extend((0..8).map(|idx| {
3038            let start = idx * 20 + 100;
3039            new_file_handle_with_size_and_sequence(
3040                FileId::random(),
3041                start,
3042                start + 9,
3043                1,
3044                idx as u64 + 10,
3045                50,
3046            )
3047        }));
3048        let windows = assign_to_windows(files.iter(), 1);
3049        let active_window = windows.keys().next().copied();
3050        let picker = TwcsPicker {
3051            trigger_file_num: 4,
3052            active_window_l1_merge_trigger: 8,
3053            inactive_window_trigger_file_num: 4,
3054            inactive_window_l1_merge_trigger: 8,
3055            time_window_seconds: Some(1),
3056            max_output_file_size: Some(512),
3057            append_mode: false,
3058            max_background_tasks: None,
3059            time_range: None,
3060        };
3061
3062        let output = picker
3063            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3064            .await
3065            .unwrap();
3066
3067        assert_eq!(1, output.len());
3068        assert_eq!(8, output[0].inputs.len());
3069        assert!(output[0].inputs.iter().all(|file| file.level() == 1));
3070    }
3071
3072    fn priority_test_files(
3073        window: i64,
3074        level: Level,
3075        file_size: u64,
3076        overlap: bool,
3077    ) -> [FileHandle; 2] {
3078        let start = window * 1_000;
3079        let ranges = if overlap {
3080            [(start, start + 499), (start + 250, start + 749)]
3081        } else {
3082            [(start, start + 99), (start + 200, start + 299)]
3083        };
3084        ranges.map(|(start, end)| {
3085            new_file_handle_with_size_and_sequence(
3086                FileId::random(),
3087                start,
3088                end,
3089                level,
3090                window as u64 + 1,
3091                file_size,
3092            )
3093        })
3094    }
3095
3096    fn priority_test_picker(max_background_tasks: usize) -> TwcsPicker {
3097        TwcsPicker {
3098            trigger_file_num: 2,
3099            active_window_l1_merge_trigger: 2,
3100            inactive_window_trigger_file_num: 2,
3101            inactive_window_l1_merge_trigger: 2,
3102            time_window_seconds: Some(1),
3103            max_output_file_size: Some(100),
3104            append_mode: false,
3105            max_background_tasks: Some(max_background_tasks),
3106            time_range: None,
3107        }
3108    }
3109
3110    fn output_window(output: &CompactionOutput) -> i64 {
3111        output.inputs[0]
3112            .time_range()
3113            .1
3114            .convert_to(TimeUnit::Second)
3115            .unwrap()
3116            .value()
3117    }
3118
3119    #[tokio::test]
3120    async fn test_older_l0_candidate_has_priority_over_newer_pure_l1_candidate() {
3121        let files = priority_test_files(0, 0, 40, false)
3122            .into_iter()
3123            .chain(priority_test_files(1, 1, 40, false))
3124            .collect::<Vec<_>>();
3125        let windows = assign_to_windows(files.iter(), 1);
3126
3127        let output = priority_test_picker(1)
3128            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3129            .await
3130            .unwrap();
3131
3132        assert_eq!(1, output.len());
3133        assert_eq!(0, output_window(&output[0]));
3134        assert!(output[0].inputs.iter().all(|file| file.level() == 0));
3135    }
3136
3137    #[tokio::test]
3138    async fn test_older_l1_file_reduction_has_priority_over_newer_overlap_only_l1() {
3139        let files = priority_test_files(0, 1, 40, false)
3140            .into_iter()
3141            .chain(priority_test_files(1, 1, 100, true))
3142            .collect::<Vec<_>>();
3143        let windows = assign_to_windows(files.iter(), 1);
3144
3145        let output = priority_test_picker(1)
3146            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3147            .await
3148            .unwrap();
3149
3150        assert_eq!(1, output.len());
3151        assert_eq!(0, output_window(&output[0]));
3152    }
3153
3154    #[tokio::test]
3155    async fn test_multiple_slots_follow_class_priority_and_newer_first_within_class() {
3156        let files = [
3157            priority_test_files(0, 0, 40, false),
3158            priority_test_files(1, 0, 40, false),
3159            priority_test_files(2, 1, 40, false),
3160            priority_test_files(3, 1, 40, false),
3161            priority_test_files(4, 1, 100, true),
3162            priority_test_files(5, 1, 100, true),
3163        ]
3164        .into_iter()
3165        .flatten()
3166        .collect::<Vec<_>>();
3167        let windows = assign_to_windows(files.iter(), 1);
3168
3169        let output = priority_test_picker(5)
3170            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(5), None)
3171            .await
3172            .unwrap();
3173        let pop_priority = output
3174            .iter()
3175            .rev()
3176            .map(|output| (output.inputs[0].level(), output_window(output)))
3177            .collect::<Vec<_>>();
3178
3179        assert_eq!(vec![(0, 1), (0, 0), (1, 3), (1, 2), (1, 5)], pop_priority);
3180    }
3181
3182    #[tokio::test]
3183    async fn test_inactive_window_mixed_fallback_merges_within_rewrite_budget() {
3184        let files = [
3185            new_file_with_level_and_rows(0, 99, 1, 1, 1_000, 1_000_000),
3186            new_file_with_level_and_rows(0, 9, 0, 2, 10, 1),
3187        ];
3188        let windows = assign_to_windows(files.iter(), 100);
3189        let picker = TwcsPicker {
3190            trigger_file_num: 4,
3191            active_window_l1_merge_trigger: 8,
3192            inactive_window_trigger_file_num: 2,
3193            inactive_window_l1_merge_trigger: 8,
3194            time_window_seconds: Some(100),
3195            max_output_file_size: Some(2_000),
3196            append_mode: false,
3197            max_background_tasks: None,
3198            time_range: None,
3199        };
3200
3201        let output = picker
3202            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3203            .await
3204            .unwrap();
3205
3206        assert_eq!(1, output.len());
3207        assert_eq!(2, output[0].inputs.len());
3208    }
3209
3210    #[tokio::test]
3211    async fn test_inactive_window_over_budget_merges_l0_only() {
3212        // L0 files are unbalanced among themselves, and merging in the huge L1 file
3213        // would exceed the rewrite budget: only the L0 files are compacted.
3214        let files = [
3215            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10_000),
3216            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 10),
3217            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 3, 1_000_000),
3218        ];
3219        let windows = assign_to_windows(files.iter(), 100);
3220        let picker = TwcsPicker {
3221            trigger_file_num: 4,
3222            active_window_l1_merge_trigger: 8,
3223            inactive_window_trigger_file_num: 2,
3224            inactive_window_l1_merge_trigger: 8,
3225            time_window_seconds: Some(100),
3226            max_output_file_size: Some(100_000),
3227            append_mode: false,
3228            max_background_tasks: None,
3229            time_range: None,
3230        };
3231
3232        let output = picker
3233            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3234            .await
3235            .unwrap();
3236
3237        assert_eq!(1, output.len());
3238        assert_eq!(2, output[0].inputs.len());
3239        assert!(output[0].inputs.iter().all(|file| file.level() == 0));
3240    }
3241
3242    #[tokio::test]
3243    async fn test_inactive_window_over_budget_without_l0_pair_does_not_merge() {
3244        // A single tiny L0 and a huge L1: merging is over budget and there is no
3245        // L0 pair to converge, so the window is left as-is.
3246        let files = [
3247            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10),
3248            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 2, 1_000_000),
3249        ];
3250        let windows = assign_to_windows(files.iter(), 100);
3251        let picker = TwcsPicker {
3252            trigger_file_num: 4,
3253            active_window_l1_merge_trigger: 8,
3254            inactive_window_trigger_file_num: 2,
3255            inactive_window_l1_merge_trigger: 8,
3256            time_window_seconds: Some(100),
3257            max_output_file_size: Some(100_000),
3258            append_mode: false,
3259            max_background_tasks: None,
3260            time_range: None,
3261        };
3262
3263        let output = picker
3264            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3265            .await
3266            .unwrap();
3267
3268        assert!(output.is_empty());
3269    }
3270
3271    #[tokio::test]
3272    async fn test_inactive_window_falls_through_when_triggered_l0_pick_fails() {
3273        // L0 reaches the inactive trigger but is unbalanced; L1 is below the
3274        // trigger. The window must still converge through the budgeted mixed
3275        // fallback instead of being skipped.
3276        let files = [
3277            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 100_000),
3278            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 10),
3279            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 3, 50_000),
3280        ];
3281        let windows = assign_to_windows(files.iter(), 100);
3282        let picker = TwcsPicker {
3283            trigger_file_num: 4,
3284            active_window_l1_merge_trigger: 8,
3285            inactive_window_trigger_file_num: 2,
3286            inactive_window_l1_merge_trigger: 8,
3287            time_window_seconds: Some(100),
3288            max_output_file_size: Some(1_000_000),
3289            append_mode: false,
3290            max_background_tasks: None,
3291            time_range: None,
3292        };
3293
3294        let output = picker
3295            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3296            .await
3297            .unwrap();
3298
3299        assert_eq!(1, output.len());
3300        assert_eq!(3, output[0].inputs.len());
3301    }
3302
3303    #[test]
3304    fn test_mixed_candidate_row_balance() {
3305        for (case, l1_rows, l0_rows, expected_len) in [
3306            ("L1 rows dominate", 1_000_000, 10_000, 0),
3307            ("L1 rows meet ratio", 60_000, 10_000, 4),
3308            ("L0 rows are unknown", 1_000_000, 0, 4),
3309        ] {
3310            let files = vec![
3311                new_file_with_level_and_rows(0, 99, 1, 1, 100, l1_rows),
3312                new_file_with_level_and_rows(0, 9, 0, 2, 100, l0_rows),
3313                new_file_with_level_and_rows(20, 29, 0, 3, 100, l0_rows),
3314                new_file_with_level_and_rows(40, 49, 0, 4, 100, l0_rows),
3315            ];
3316
3317            let picked = pick_mixed_count_first(vec![SortedRun::from(files)], None);
3318
3319            assert_eq!(expected_len, picked.len(), "{case}");
3320        }
3321    }
3322
3323    #[test]
3324    fn test_count_first_prefers_smaller_bytes_when_file_counts_match() {
3325        let files = (0..=DEFAULT_MAX_INPUT_FILES)
3326            .map(|idx| {
3327                let start = idx as i64 * 20;
3328                let size = if idx == 0 {
3329                    100
3330                } else if idx == DEFAULT_MAX_INPUT_FILES {
3331                    1
3332                } else {
3333                    10
3334                };
3335                new_file(start, start + 9, idx as u64 + 1, size)
3336            })
3337            .collect::<Vec<_>>();
3338
3339        let picked = pick_count_first(vec![SortedRun::from(files)], None);
3340        let ranges = picked_ranges(&picked);
3341
3342        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3343        assert_eq!(Some(&(20, 29)), ranges.first());
3344        let last_start = DEFAULT_MAX_INPUT_FILES as i64 * 20;
3345        assert_eq!(Some(&(last_start, last_start + 9)), ranges.last());
3346    }
3347
3348    #[test]
3349    fn test_count_first_skips_pure_rewrite_without_progress() {
3350        // Four non-overlapping files whose combined size splits back into at least
3351        // four outputs: compacting them reduces neither the file count nor any
3352        // overlap, so there is nothing worth picking.
3353        let picked = pick_count_first(
3354            vec![SortedRun::from(vec![
3355                new_file(0, 9, 1, 600),
3356                new_file(20, 29, 2, 600),
3357                new_file(40, 49, 3, 600),
3358                new_file(60, 69, 4, 600),
3359            ])],
3360            Some(512),
3361        );
3362
3363        assert!(picked.is_empty());
3364    }
3365
3366    #[test]
3367    fn test_count_first_allows_overlap_resolution_without_file_reduction() {
3368        // Two large overlapping files from different runs: compacting them cannot
3369        // reduce the file count (the output splits back into just as many files),
3370        // but it resolves the overlap and merges two sorted runs into one.
3371        let picked = pick_count_first(
3372            vec![
3373                SortedRun::from(vec![new_file(0, 19, 1, 600)]),
3374                SortedRun::from(vec![new_file(10, 29, 2, 600)]),
3375            ],
3376            Some(512),
3377        );
3378
3379        assert_eq!(vec![(0, 19), (10, 29)], picked_ranges(&picked));
3380    }
3381
3382    #[test]
3383    fn test_count_first_prefers_guaranteed_reduction_over_pure_rewrite() {
3384        // A window mixing large and small files: the large triple on its own is a
3385        // pure rewrite, and every interval containing large files rewrites more
3386        // bytes for the same predicted reduction, so the small triple wins.
3387        let picked = pick_count_first(
3388            vec![SortedRun::from(vec![
3389                new_file(0, 9, 1, 600),
3390                new_file(10, 19, 2, 600),
3391                new_file(20, 29, 3, 600),
3392                new_file(30, 39, 4, 10),
3393                new_file(40, 49, 5, 10),
3394                new_file(50, 59, 6, 10),
3395            ])],
3396            Some(512),
3397        );
3398
3399        assert_eq!(vec![(30, 39), (40, 49), (50, 59)], picked_ranges(&picked));
3400    }
3401
3402    #[test]
3403    fn test_count_first_prefers_earlier_time_on_exact_tie() {
3404        let files = (0..=DEFAULT_MAX_INPUT_FILES)
3405            .map(|idx| {
3406                let start = idx as i64 * 20;
3407                new_file(start, start + 9, idx as u64 + 1, 10)
3408            })
3409            .collect::<Vec<_>>();
3410
3411        let picked = pick_count_first(vec![SortedRun::from(files)], None);
3412        let ranges = picked_ranges(&picked);
3413
3414        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3415        assert_eq!(Some(&(0, 9)), ranges.first());
3416        let last_start = (DEFAULT_MAX_INPUT_FILES as i64 - 1) * 20;
3417        assert_eq!(Some(&(last_start, last_start + 9)), ranges.last());
3418    }
3419
3420    #[test]
3421    fn test_count_first_keeps_each_interleaved_run_contiguous() {
3422        let picked = pick_count_first(
3423            vec![
3424                SortedRun::from(vec![
3425                    new_file(0, 9, 1, 1),
3426                    new_file(20, 29, 2, 1),
3427                    new_file(40, 49, 3, 1),
3428                ]),
3429                SortedRun::from(vec![new_file(10, 19, 4, 1), new_file(30, 39, 5, 1)]),
3430            ],
3431            None,
3432        );
3433
3434        assert_eq!(
3435            vec![(0, 9), (10, 19), (20, 29), (30, 39), (40, 49)],
3436            picked_ranges(&picked)
3437        );
3438    }
3439
3440    // TODO(hl): TTL tester that checks if get_expired_ssts function works as expected.
3441
3442    // Reproduces the leftover-small-files deadlock observed in the compaction bench:
3443    // an "EngineFull" flush emits several SSTs sharing one sequence. They must be ordinary
3444    // candidates so a contiguous candidate can span them instead of treating them as an
3445    // indivisible block that fragments the timeline.
3446    #[test]
3447    fn test_count_first_candidate_spans_same_sequence_files() {
3448        let picked = pick_count_first(
3449            vec![SortedRun::from(vec![
3450                new_file(0, 9, 1, 10),
3451                // One EngineFull flush output: three SSTs with the same sequence.
3452                new_file(10, 19, 2, 10),
3453                new_file(10, 19, 2, 10),
3454                new_file(10, 19, 2, 10),
3455                new_file(20, 29, 3, 10),
3456            ])],
3457            None,
3458        );
3459
3460        assert_eq!(
3461            vec![(0, 9), (10, 19), (10, 19), (10, 19), (20, 29)],
3462            picked_ranges(&picked)
3463        );
3464    }
3465
3466    // End-to-end repro of the compaction-bench leftover files: a window where
3467    // EngineFull flush groups (multiple SSTs sharing a sequence) sit between
3468    // singleton flushes. The picker must be able to merge the whole contiguous
3469    // range; otherwise these files can never be compacted once the active window
3470    // moves on.
3471    #[tokio::test]
3472    async fn test_count_first_merges_window_with_interleaved_flush_groups() {
3473        let files = [
3474            // Singleton flush.
3475            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10),
3476            // EngineFull flush: three SSTs sharing sequence 2.
3477            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
3478            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
3479            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
3480            // Singleton flush.
3481            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 3, 10),
3482        ];
3483        let windows = assign_to_windows(files.iter(), 100);
3484        let picker = TwcsPicker {
3485            trigger_file_num: 3,
3486            active_window_l1_merge_trigger: 8,
3487            inactive_window_trigger_file_num: 3,
3488            inactive_window_l1_merge_trigger: 8,
3489            time_window_seconds: Some(100),
3490            max_output_file_size: None,
3491            append_mode: false,
3492            max_background_tasks: None,
3493            time_range: None,
3494        };
3495
3496        let output = picker
3497            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3498            .await
3499            .unwrap();
3500
3501        assert_eq!(1, output.len());
3502        // All five SSTs of the window are merged in one compaction.
3503        assert_eq!(5, output[0].inputs.len());
3504    }
3505}