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