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    /// Start of the half-open window, in seconds.
973    time_window: i64,
974    primary_key_range: Option<(bytes::Bytes, bytes::Bytes)>,
975}
976
977impl Window {
978    /// Creates a new [Window] with given file.
979    fn new_with_file(file: FileHandle, mapper: &PrimaryKeyRangeMapper) -> Self {
980        let (start, end) = file.time_range();
981        let primary_key_range = file.primary_key_range(mapper);
982        Self {
983            start,
984            end,
985            files: vec![file],
986            time_window: 0,
987            primary_key_range,
988        }
989    }
990
991    /// Returns the time range of all files in current window (inclusive).
992    fn range(&self) -> (Timestamp, Timestamp) {
993        (self.start, self.end)
994    }
995
996    /// Adds a new file to window and updates time range.
997    fn add_file(&mut self, file: FileHandle, mapper: &PrimaryKeyRangeMapper) {
998        let (start, end) = file.time_range();
999        self.start = self.start.min(start);
1000        self.end = self.end.max(end);
1001        self.primary_key_range = merge_primary_key_ranges(
1002            self.primary_key_range.take(),
1003            file.primary_key_range(mapper),
1004        );
1005        self.files.push(file);
1006    }
1007
1008    fn files(&self) -> impl Iterator<Item = &FileHandle> {
1009        self.files.iter()
1010    }
1011}
1012
1013/// Assigns files to windows with predefined window size (in seconds) by their max timestamps.
1014fn assign_to_windows<'a>(
1015    files: impl Iterator<Item = &'a FileHandle>,
1016    time_window_size: i64,
1017    mapper: &PrimaryKeyRangeMapper,
1018) -> BTreeMap<i64, Window> {
1019    let mut windows: HashMap<i64, Window> = HashMap::new();
1020    // Iterates all files and assign to time windows according to max timestamp
1021    for f in files {
1022        if f.compacting() {
1023            continue;
1024        }
1025        let (_, end) = f.time_range();
1026        let time_window = time_window_start(end, time_window_size);
1027
1028        match windows.entry(time_window) {
1029            Entry::Occupied(mut e) => {
1030                e.get_mut().add_file(f.clone(), mapper);
1031            }
1032            Entry::Vacant(e) => {
1033                let mut window = Window::new_with_file(f.clone(), mapper);
1034                window.time_window = time_window;
1035                e.insert(window);
1036            }
1037        }
1038    }
1039    windows.into_iter().collect()
1040}
1041
1042/// Uses the same half-open windows as memtables, including timestamps in the first
1043/// second after a boundary. An unrepresentable window start is clamped to `i64::MIN`.
1044fn time_window_start(timestamp: Timestamp, time_window_size: i64) -> i64 {
1045    // Conversion to seconds cannot overflow.
1046    timestamp
1047        .convert_to(TimeUnit::Second)
1048        .and_then(|ts| ts.value().align_by_bucket(time_window_size))
1049        .unwrap_or(i64::MIN)
1050}
1051
1052fn time_window_intersects_range(
1053    window_start: i64,
1054    time_window_size: i64,
1055    time_range: &TimestampRange,
1056) -> bool {
1057    if time_range.is_empty() {
1058        return false;
1059    }
1060    let first_window = time_range
1061        .start()
1062        .map_or(i64::MIN, |start| time_window_start(start, time_window_size));
1063    let last_window = match time_range.end() {
1064        None => i64::MAX,
1065        Some(end) => {
1066            // The range end is exclusive; keep the last second that intersects it.
1067            let Some(last_second) = end
1068                .convert_to_ceil(TimeUnit::Second)
1069                .and_then(|timestamp| timestamp.value().checked_sub(1))
1070            else {
1071                return false;
1072            };
1073            time_window_start(Timestamp::new_second(last_second), time_window_size)
1074        }
1075    };
1076    (first_window..=last_window).contains(&window_start)
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.
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.map(|ts| time_window_start(ts, time_window_size))
1111}
1112
1113/// Finds the active window from the file with the highest sequence number.
1114///
1115/// Flush and compaction outputs inherit the max input sequence, so the
1116/// max-sequence file always tracks the most recent write: the active window
1117/// survives the transient state where level 0 is empty right after its files
1118/// were compacted away. Returns `None` when no file carries a sequence.
1119///
1120/// The window key is its inclusive start, as in [`assign_to_windows`].
1121fn find_active_window_by_sequence<'a>(
1122    files: impl Iterator<Item = &'a FileHandle>,
1123    time_window_size: i64,
1124) -> Option<i64> {
1125    files
1126        .filter(|f| f.meta_ref().sequence.is_some())
1127        .max_by_key(|f| f.meta_ref().sequence)
1128        .map(|f| time_window_start(f.time_range().1, time_window_size))
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use std::collections::HashSet;
1134    use std::num::NonZeroU64;
1135    use std::sync::Arc;
1136    use std::time::Duration;
1137
1138    use common_base::Plugins;
1139    use common_time::range::TimestampRange;
1140    use store_api::storage::FileId;
1141
1142    use super::*;
1143    use crate::cache::CacheManager;
1144    use crate::compaction::compactor::CompactionVersion;
1145    use crate::compaction::test_util::{
1146        compaction_region_with_ssts, new_file_handle, new_file_handle_with_sequence,
1147        new_file_handle_with_size_and_sequence,
1148        new_file_handle_with_size_sequence_and_primary_key_range, pk_range,
1149        primary_key_mapper_for_test,
1150    };
1151    use crate::config::MitoConfig;
1152    use crate::region::options::RegionOptions;
1153    use crate::sst::file::{FileMeta, Level};
1154    use crate::sst::version::SstVersion;
1155    use crate::test_util::memtable_util::metadata_for_test;
1156    use crate::test_util::scheduler_util::SchedulerEnv;
1157
1158    fn assign_to_windows<'a>(
1159        files: impl Iterator<Item = &'a FileHandle>,
1160        time_window_size: i64,
1161    ) -> BTreeMap<i64, Window> {
1162        super::assign_to_windows(files, time_window_size, &primary_key_mapper_for_test())
1163    }
1164
1165    fn pick_count_first(
1166        sorted_runs: Vec<SortedRun<FileHandle>>,
1167        max_output_file_size: Option<u64>,
1168    ) -> Vec<FileHandle> {
1169        super::pick_count_first(
1170            sorted_runs,
1171            max_output_file_size,
1172            &primary_key_mapper_for_test(),
1173        )
1174    }
1175
1176    fn pick_mixed_count_first(
1177        sorted_runs: Vec<SortedRun<FileHandle>>,
1178        max_output_file_size: Option<u64>,
1179    ) -> Vec<FileHandle> {
1180        super::pick_mixed_count_first(
1181            sorted_runs,
1182            max_output_file_size,
1183            &primary_key_mapper_for_test(),
1184        )
1185    }
1186
1187    impl TwcsPicker {
1188        async fn build_output_with_time_range(
1189            &self,
1190            region_id: RegionId,
1191            time_windows: BTreeMap<i64, Window>,
1192            active_window: Option<i64>,
1193            time_window_size: Option<i64>,
1194        ) -> Result<Vec<CompactionOutput>> {
1195            self.build_output(
1196                region_id,
1197                time_windows,
1198                WindowOutputContext {
1199                    active_window,
1200                    time_window_size,
1201                    max_outputs: self.max_background_tasks,
1202                    primary_key_mapper: Arc::new(primary_key_mapper_for_test()),
1203                },
1204            )
1205            .await
1206        }
1207    }
1208
1209    #[test]
1210    fn test_cross_schema_pk_overlap_before_window_aggregation() {
1211        use crate::test_util::sst_util::{new_primary_key, sst_region_metadata};
1212
1213        let metadata = Arc::new(sst_region_metadata());
1214        let mut old = FileMeta {
1215            region_id: metadata.region_id,
1216            file_id: FileId::random(),
1217            time_range: (
1218                Timestamp::new_millisecond(0),
1219                Timestamp::new_millisecond(1000),
1220            ),
1221            primary_key_min: Some(new_primary_key(&["a"]).into()),
1222            primary_key_max: Some(new_primary_key(&["b"]).into()),
1223            ..Default::default()
1224        };
1225        let mut completed = new_primary_key(&["b"]);
1226        completed.push(0); // The appended nullable tag's default.
1227        let new = FileMeta {
1228            file_id: FileId::random(),
1229            time_range: (
1230                Timestamp::new_millisecond(500),
1231                Timestamp::new_millisecond(2000),
1232            ),
1233            primary_key_min: Some(completed.clone().into()),
1234            primary_key_max: Some(completed.clone().into()),
1235            ..old.clone()
1236        };
1237        assert!(old.primary_key_max < new.primary_key_min);
1238        let mut ssts = SstVersion::new(metadata);
1239        let old_id = old.file_id;
1240        let new_id = new.file_id;
1241        ssts.add_files(
1242            crate::test_util::new_noop_file_purger(),
1243            [old.clone(), new].into_iter(),
1244        );
1245        let old_file = &ssts.levels()[0].files[&old_id];
1246        let new_file = &ssts.levels()[0].files[&new_id];
1247        let ranges = ssts.primary_key_mapper();
1248        let windows = super::assign_to_windows([old_file, new_file].into_iter(), 1, &ranges);
1249        assert_eq!(2, windows.len());
1250        assert!(
1251            windows
1252                .values()
1253                .all(|window| window_has_overlap(window, &windows))
1254        );
1255
1256        let mut window = Window::new_with_file(old_file.clone(), &ranges);
1257        window.add_file(new_file.clone(), &ranges);
1258        assert!(selected_overlaps_unselected(
1259            std::slice::from_ref(new_file),
1260            &window,
1261            &ranges,
1262        ));
1263        assert_eq!(
1264            Some(completed.into()),
1265            window
1266                .primary_key_range
1267                .as_ref()
1268                .map(|range| range.1.clone())
1269        );
1270
1271        // In the same target schema, a genuinely disjoint range still prunes.
1272        old.file_id = FileId::random();
1273        old.primary_key_max = old.primary_key_min.clone();
1274        let disjoint_id = old.file_id;
1275        ssts.add_files(crate::test_util::new_noop_file_purger(), [old].into_iter());
1276        let files = &ssts.levels()[0].files;
1277        assert!(!files_overlap_inclusive(
1278            &files[&disjoint_id],
1279            &files[&new_id],
1280            &ranges
1281        ));
1282    }
1283
1284    #[test]
1285    fn test_valid_max_input_files_env_overrides_default() {
1286        assert_eq!(64, parse_max_input_files(Some("64")));
1287    }
1288
1289    #[test]
1290    fn test_invalid_max_input_files_env_falls_back_to_default() {
1291        for env_value in [None, Some(""), Some("invalid"), Some("0"), Some("1")] {
1292            assert_eq!(16, parse_max_input_files(env_value));
1293        }
1294    }
1295
1296    async fn compaction_region_with_expired_sst() -> CompactionRegion {
1297        compaction_region_with_ssts(
1298            (1..=4).map(|sequence| FileMeta {
1299                file_id: FileId::random(),
1300                time_range: (
1301                    Timestamp::new_millisecond(0),
1302                    Timestamp::new_millisecond(10),
1303                ),
1304                level: 0,
1305                sequence: NonZeroU64::new(sequence),
1306                ..Default::default()
1307            }),
1308            Duration::from_millis(1),
1309        )
1310        .await
1311    }
1312
1313    #[tokio::test]
1314    async fn test_pick_normalizes_zero_output_size_to_unlimited() {
1315        let mut compaction_region = compaction_region_with_ssts(
1316            (1..=4).map(|sequence| new_file(0, 10, sequence, 100).meta_ref().clone()),
1317            Duration::from_secs(3600),
1318        )
1319        .await;
1320        compaction_region.ttl = None;
1321
1322        for (max_output_file_size, expected_max_file_size) in
1323            [(Some(0), None), (None, None), (Some(1024), Some(1024))]
1324        {
1325            let picker = TwcsPicker {
1326                trigger_file_num: 4,
1327                active_window_l1_merge_trigger: 8,
1328                inactive_window_trigger_file_num: 4,
1329                inactive_window_l1_merge_trigger: 8,
1330                time_window_seconds: Some(3),
1331                max_output_file_size,
1332                append_mode: false,
1333                max_background_tasks: None,
1334                time_range: None,
1335            };
1336
1337            let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1338
1339            assert_eq!(output.outputs.len(), 1);
1340            assert_eq!(output.outputs[0].inputs.len(), 4);
1341            assert!(output.expired_ssts.is_empty());
1342            assert_eq!(output.max_file_size, expected_max_file_size);
1343        }
1344    }
1345
1346    #[tokio::test]
1347    async fn test_pick_expired_ssts_without_marking_compacting() {
1348        let picker = TwcsPicker {
1349            trigger_file_num: 4,
1350            active_window_l1_merge_trigger: 8,
1351            inactive_window_trigger_file_num: 4,
1352            inactive_window_l1_merge_trigger: 8,
1353            time_window_seconds: Some(3),
1354            max_output_file_size: None,
1355            append_mode: false,
1356            max_background_tasks: None,
1357            time_range: None,
1358        };
1359        let compaction_region = compaction_region_with_expired_sst().await;
1360
1361        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1362
1363        assert!(output.outputs.is_empty());
1364        assert!(!output.expired_ssts.is_empty());
1365        assert!(output.expired_ssts.iter().all(|file| !file.compacting()));
1366    }
1367
1368    #[tokio::test]
1369    async fn test_expired_sst_does_not_determine_active_window() {
1370        let now = Timestamp::current_millis().value();
1371        let files = [
1372            FileMeta {
1373                file_id: FileId::random(),
1374                time_range: (
1375                    Timestamp::new_millisecond(0),
1376                    Timestamp::new_millisecond(10),
1377                ),
1378                level: 0,
1379                sequence: NonZeroU64::new(100),
1380                ..Default::default()
1381            },
1382            FileMeta {
1383                file_id: FileId::random(),
1384                time_range: (
1385                    Timestamp::new_millisecond(now - 1000),
1386                    Timestamp::new_millisecond(now),
1387                ),
1388                level: 0,
1389                sequence: NonZeroU64::new(1),
1390                ..Default::default()
1391            },
1392            FileMeta {
1393                file_id: FileId::random(),
1394                time_range: (
1395                    Timestamp::new_millisecond(now - 1000),
1396                    Timestamp::new_millisecond(now),
1397                ),
1398                level: 0,
1399                sequence: NonZeroU64::new(2),
1400                ..Default::default()
1401            },
1402        ];
1403        let compaction_region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
1404        let picker = TwcsPicker {
1405            trigger_file_num: 4,
1406            active_window_l1_merge_trigger: 8,
1407            inactive_window_trigger_file_num: 2,
1408            inactive_window_l1_merge_trigger: 8,
1409            time_window_seconds: Some(3),
1410            max_output_file_size: None,
1411            append_mode: false,
1412            max_background_tasks: None,
1413            time_range: None,
1414        };
1415
1416        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1417
1418        assert_eq!(1, output.expired_ssts.len());
1419        assert!(output.outputs.is_empty());
1420    }
1421
1422    #[tokio::test]
1423    async fn test_active_window_survives_l0_consumption() {
1424        // Simulates the transient state right after an L0 compaction: level 0 is
1425        // empty and only L1 files remain. The active window must still be the
1426        // window of the most recent write (tracked by the max-sequence file), so
1427        // the window is NOT compacted under the inactive rules.
1428        let env = SchedulerEnv::new().await;
1429        let metadata = metadata_for_test();
1430        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
1431        let mut ssts = SstVersion::new(metadata.clone());
1432        ssts.add_files(
1433            Arc::new(crate::sst::file_purger::NoopFilePurger),
1434            [100, 101].into_iter().map(|sequence| FileMeta {
1435                region_id: metadata.region_id,
1436                file_id: FileId::random(),
1437                time_range: (
1438                    Timestamp::new_millisecond(0),
1439                    Timestamp::new_millisecond(10),
1440                ),
1441                level: 1,
1442                sequence: NonZeroU64::new(sequence),
1443                ..Default::default()
1444            }),
1445        );
1446        let compaction_region = CompactionRegion {
1447            region_id: metadata.region_id,
1448            region_options: RegionOptions::default(),
1449            engine_config: Arc::new(MitoConfig::default()),
1450            region_metadata: metadata.clone(),
1451            cache_manager: Arc::new(CacheManager::default()),
1452            access_layer: env.access_layer,
1453            manifest_ctx,
1454            current_version: CompactionVersion {
1455                metadata,
1456                options: RegionOptions::default(),
1457                ssts: Arc::new(ssts),
1458                memtable_min_sequence: None,
1459                compaction_time_window: None,
1460            },
1461            file_purger: None,
1462            ttl: None,
1463            max_parallelism: 1,
1464            plugins: Plugins::new(),
1465        };
1466        let picker = TwcsPicker {
1467            trigger_file_num: 4,
1468            active_window_l1_merge_trigger: 8,
1469            inactive_window_trigger_file_num: 2,
1470            inactive_window_l1_merge_trigger: 8,
1471            time_window_seconds: Some(3600),
1472            max_output_file_size: None,
1473            append_mode: false,
1474            max_background_tasks: None,
1475            time_range: None,
1476        };
1477
1478        assert!(picker.pick(&compaction_region).await.unwrap().is_none());
1479    }
1480
1481    #[test]
1482    fn test_find_active_window_by_sequence() {
1483        // The max-sequence file tracks the most recent write regardless of
1484        // level: here a compacted L1 file ending at 2999ms (window key 2) is
1485        // newer than the L0 file ending at 999ms (window key 0). Window keys
1486        // follow the assign_to_windows convention (truncate to seconds).
1487        let files = [
1488            new_file_handle_with_sequence(FileId::random(), 0, 999, 0, 1),
1489            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 1, 10),
1490        ];
1491        assert_eq!(Some(2), find_active_window_by_sequence(files.iter(), 1));
1492
1493        // Backfill: the newest write lands in an older window, and that window
1494        // is the active one.
1495        let files = [
1496            new_file_handle_with_sequence(FileId::random(), 0, 999, 0, 10),
1497            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 0, 1),
1498        ];
1499        assert_eq!(Some(0), find_active_window_by_sequence(files.iter(), 1));
1500
1501        // Files without a sequence are skipped; if none have one, the caller
1502        // falls back to the L0-based rule.
1503        let files = [new_file_handle_with_sequence(
1504            FileId::random(),
1505            0,
1506            999,
1507            1,
1508            0,
1509        )];
1510        assert_eq!(None, find_active_window_by_sequence(files.iter(), 1));
1511        assert!(find_active_window_by_sequence(Vec::<FileHandle>::new().iter(), 1).is_none());
1512    }
1513
1514    #[test]
1515    fn test_active_window_falls_back_to_l0_rule_without_sequence() {
1516        let active_window_of = |files: &[FileHandle]| {
1517            find_active_window_by_sequence(files.iter(), 1).or_else(|| {
1518                find_latest_window_in_seconds(files.iter().filter(|f| f.level() == 0), 1)
1519            })
1520        };
1521
1522        // Legacy files without sequence: the L0-based rule still applies.
1523        let files = [
1524            new_file_handle_with_sequence(FileId::random(), 0, 999, 1, 0),
1525            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 0, 0),
1526        ];
1527        assert_eq!(Some(2), active_window_of(&files));
1528
1529        // No sequence and no L0: None, exactly as before.
1530        let files = [new_file_handle_with_sequence(
1531            FileId::random(),
1532            0,
1533            999,
1534            1,
1535            0,
1536        )];
1537        assert_eq!(None, active_window_of(&files));
1538    }
1539
1540    #[test]
1541    fn test_get_latest_window_in_seconds() {
1542        assert_eq!(
1543            Some(0),
1544            find_latest_window_in_seconds([new_file_handle(FileId::random(), 0, 999, 0)].iter(), 1)
1545        );
1546        assert_eq!(
1547            Some(1),
1548            find_latest_window_in_seconds(
1549                [new_file_handle(FileId::random(), 0, 1000, 0)].iter(),
1550                1
1551            )
1552        );
1553
1554        assert_eq!(
1555            Some(-9223372036857600),
1556            find_latest_window_in_seconds(
1557                [new_file_handle(FileId::random(), i64::MIN, i64::MIN + 1, 0)].iter(),
1558                3600,
1559            )
1560        );
1561
1562        assert_eq!(
1563            (i64::MAX / 10000000) * 10000,
1564            find_latest_window_in_seconds(
1565                [new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0)].iter(),
1566                10000,
1567            )
1568            .unwrap()
1569        );
1570
1571        assert_eq!(
1572            Some((i64::MAX / 3600000) * 3600),
1573            find_latest_window_in_seconds(
1574                [
1575                    new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0),
1576                    new_file_handle(FileId::random(), 0, 1000, 0)
1577                ]
1578                .iter(),
1579                3600
1580            )
1581        );
1582    }
1583
1584    #[test]
1585    fn test_window_assignment_and_activity_at_boundaries() {
1586        // Before, at, and after positive/negative boundaries, including the first
1587        // fractional second: all picker paths must agree with half-open partitions.
1588        for (unit, ticks_per_second) in [
1589            (TimeUnit::Second, 1),
1590            (TimeUnit::Millisecond, 1_000),
1591            (TimeUnit::Microsecond, 1_000_000),
1592            (TimeUnit::Nanosecond, 1_000_000_000),
1593        ] {
1594            for window_size in [1, 3, 86_400] {
1595                for boundary in [-window_size, 0, window_size] {
1596                    for (offset, expected_window) in [
1597                        (-1, boundary - window_size),
1598                        (0, boundary),
1599                        (ticks_per_second - 1, boundary),
1600                        (ticks_per_second, boundary + i64::from(window_size == 1)),
1601                    ] {
1602                        let timestamp = Timestamp::new(boundary * ticks_per_second + offset, unit);
1603                        let file = FileHandle::new(
1604                            FileMeta {
1605                                time_range: (timestamp, timestamp),
1606                                sequence: NonZeroU64::new(1),
1607                                ..Default::default()
1608                            },
1609                            crate::test_util::new_noop_file_purger(),
1610                        );
1611                        let files = [file];
1612                        let windows = assign_to_windows(files.iter(), window_size);
1613                        assert_eq!(
1614                            vec![expected_window],
1615                            windows.keys().copied().collect::<Vec<_>>(),
1616                            "{timestamp:?}, window size {window_size}"
1617                        );
1618                        assert_eq!(
1619                            Some(expected_window),
1620                            find_active_window_by_sequence(files.iter(), window_size)
1621                        );
1622                        assert_eq!(
1623                            Some(expected_window),
1624                            find_latest_window_in_seconds(files.iter(), window_size)
1625                        );
1626                    }
1627                }
1628            }
1629        }
1630    }
1631
1632    #[test]
1633    fn test_window_assignment_at_timestamp_limits() {
1634        for (seconds, expected_window) in [(i64::MIN, i64::MIN), (i64::MAX, i64::MAX - 1)] {
1635            let timestamp = Timestamp::new_second(seconds);
1636            let files = [FileHandle::new(
1637                FileMeta {
1638                    time_range: (timestamp, timestamp),
1639                    sequence: NonZeroU64::new(1),
1640                    ..Default::default()
1641                },
1642                crate::test_util::new_noop_file_purger(),
1643            )];
1644            let windows = assign_to_windows(files.iter(), 3);
1645            assert_eq!(
1646                vec![expected_window],
1647                windows.keys().copied().collect::<Vec<_>>()
1648            );
1649            assert_eq!(
1650                Some(expected_window),
1651                find_active_window_by_sequence(files.iter(), 3)
1652            );
1653            assert_eq!(
1654                Some(expected_window),
1655                find_latest_window_in_seconds(files.iter(), 3)
1656            );
1657            assert!(time_window_intersects_range(
1658                expected_window,
1659                3,
1660                &TimestampRange::single(timestamp)
1661            ));
1662        }
1663    }
1664
1665    #[tokio::test]
1666    async fn test_midnight_file_is_not_compacted_with_previous_day() {
1667        // Regression for #9441: flush already separates the midnight-only SST.
1668        let midnight = 86_400_000;
1669        CompactionPickerTestCase {
1670            window_size: 86_400,
1671            input_files: [
1672                (midnight - 240_000, midnight - 180_000),
1673                (midnight - 180_000, midnight - 90_000),
1674                (midnight - 90_000, midnight - 30_000),
1675                (midnight, midnight),
1676            ]
1677            .into_iter()
1678            .enumerate()
1679            .map(|(index, (start, end))| {
1680                new_file_handle_with_sequence(FileId::random(), start, end, 0, index as u64 + 1)
1681            })
1682            .collect(),
1683            expected_outputs: vec![ExpectedOutput {
1684                input_files: vec![0, 1, 2],
1685                output_level: 1,
1686            }],
1687        }
1688        .check()
1689        .await;
1690    }
1691
1692    #[test]
1693    fn test_assign_to_windows() {
1694        let windows = assign_to_windows(
1695            [
1696                new_file_handle(FileId::random(), 0, 999, 0),
1697                new_file_handle(FileId::random(), 0, 999, 0),
1698                new_file_handle(FileId::random(), 0, 999, 0),
1699                new_file_handle(FileId::random(), 0, 999, 0),
1700                new_file_handle(FileId::random(), 0, 999, 0),
1701            ]
1702            .iter(),
1703            3,
1704        );
1705        let fgs = &windows.get(&0).unwrap().files;
1706        assert_eq!(5, fgs.len());
1707
1708        let files = [FileId::random(); 3];
1709        let windows = assign_to_windows(
1710            [
1711                new_file_handle(files[0], -2000, -3, 0),
1712                new_file_handle(files[1], 0, 2999, 0),
1713                new_file_handle(files[2], 50, 10001, 0),
1714            ]
1715            .iter(),
1716            3,
1717        );
1718        assert_eq!(
1719            files[0],
1720            windows
1721                .get(&-3)
1722                .unwrap()
1723                .files()
1724                .next()
1725                .unwrap()
1726                .file_id()
1727                .file_id()
1728        );
1729        assert_eq!(
1730            files[1],
1731            windows
1732                .get(&0)
1733                .unwrap()
1734                .files()
1735                .next()
1736                .unwrap()
1737                .file_id()
1738                .file_id()
1739        );
1740        assert_eq!(
1741            files[2],
1742            windows
1743                .get(&9)
1744                .unwrap()
1745                .files()
1746                .next()
1747                .unwrap()
1748                .file_id()
1749                .file_id()
1750        );
1751    }
1752
1753    #[test]
1754    fn test_assign_files_to_windows() {
1755        let files = [
1756            FileId::random(),
1757            FileId::random(),
1758            FileId::random(),
1759            FileId::random(),
1760        ];
1761        let windows = assign_to_windows(
1762            [
1763                new_file_handle_with_sequence(files[0], 0, 999, 0, 1),
1764                new_file_handle_with_sequence(files[1], 0, 999, 0, 1),
1765                new_file_handle_with_sequence(files[2], 0, 999, 0, 2),
1766                new_file_handle_with_sequence(files[3], 0, 999, 0, 2),
1767            ]
1768            .iter(),
1769            3,
1770        );
1771        assert_eq!(windows.len(), 1);
1772        let window_files = &windows.get(&0).unwrap().files;
1773        assert_eq!(4, window_files.len());
1774        assert_eq!(
1775            window_files
1776                .iter()
1777                .map(|f| f.file_id().file_id())
1778                .collect::<HashSet<_>>(),
1779            files.into_iter().collect()
1780        );
1781    }
1782
1783    #[test]
1784    fn test_assign_compacting_to_windows() {
1785        let files = [
1786            new_file_handle(FileId::random(), 0, 999, 0),
1787            new_file_handle(FileId::random(), 0, 999, 0),
1788            new_file_handle(FileId::random(), 0, 999, 0),
1789            new_file_handle(FileId::random(), 0, 999, 0),
1790            new_file_handle(FileId::random(), 0, 999, 0),
1791        ];
1792        files[0].set_compacting(true);
1793        files[2].set_compacting(true);
1794        let mut windows = assign_to_windows(files.iter(), 3);
1795        let window0 = windows.remove(&0).unwrap();
1796        assert_eq!(3, window0.files.len());
1797        let candidates = window0
1798            .files
1799            .iter()
1800            .map(|f| f.file_id().file_id())
1801            .collect::<HashSet<_>>();
1802        assert_eq!(candidates.len(), 3);
1803        assert_eq!(
1804            candidates,
1805            [
1806                files[1].file_id().file_id(),
1807                files[3].file_id().file_id(),
1808                files[4].file_id().file_id()
1809            ]
1810            .into_iter()
1811            .collect::<HashSet<_>>()
1812        );
1813    }
1814
1815    /// (Window value, overlapping, files' time ranges in window)
1816    type ExpectedWindowSpec = (i64, bool, Vec<(i64, i64)>);
1817
1818    fn check_assign_to_windows_with_overlapping(
1819        file_time_ranges: &[(i64, i64)],
1820        time_window: i64,
1821        expected_files: &[ExpectedWindowSpec],
1822    ) {
1823        let files: Vec<_> = (0..file_time_ranges.len())
1824            .map(|_| FileId::random())
1825            .collect();
1826
1827        let file_handles = files
1828            .iter()
1829            .zip(file_time_ranges.iter())
1830            .map(|(file_id, range)| new_file_handle(*file_id, range.0, range.1, 0))
1831            .collect::<Vec<_>>();
1832
1833        let windows = assign_to_windows(file_handles.iter(), time_window);
1834        assert_eq!(expected_files.len(), windows.len());
1835
1836        for (expected_window, overlapping, window_files) in expected_files {
1837            let actual_window = windows.get(expected_window).unwrap();
1838            let actual_overlapping = window_has_overlap(actual_window, &windows);
1839            assert_eq!(*overlapping, actual_overlapping);
1840            let mut file_ranges = actual_window
1841                .files
1842                .iter()
1843                .map(|f| {
1844                    let (s, e) = f.time_range();
1845                    (s.value(), e.value())
1846                })
1847                .collect::<Vec<_>>();
1848            file_ranges.sort_unstable_by(|l, r| l.0.cmp(&r.0).then(l.1.cmp(&r.1)));
1849            assert_eq!(window_files, &file_ranges);
1850        }
1851    }
1852
1853    #[test]
1854    fn test_assign_to_windows_with_overlapping() {
1855        check_assign_to_windows_with_overlapping(
1856            &[(0, 999), (1000, 1999), (2000, 2999)],
1857            2,
1858            &[
1859                (0, false, vec![(0, 999), (1000, 1999)]),
1860                (2, false, vec![(2000, 2999)]),
1861            ],
1862        );
1863
1864        check_assign_to_windows_with_overlapping(
1865            &[(0, 1), (0, 999), (100, 2999)],
1866            2,
1867            &[
1868                (0, true, vec![(0, 1), (0, 999)]),
1869                (2, true, vec![(100, 2999)]),
1870            ],
1871        );
1872
1873        check_assign_to_windows_with_overlapping(
1874            &[(0, 999), (1000, 1999), (2000, 2999), (3000, 3999)],
1875            2,
1876            &[
1877                (0, false, vec![(0, 999), (1000, 1999)]),
1878                (2, false, vec![(2000, 2999), (3000, 3999)]),
1879            ],
1880        );
1881
1882        check_assign_to_windows_with_overlapping(
1883            &[
1884                (0, 999),
1885                (1000, 1999),
1886                (2000, 2999),
1887                (3000, 3999),
1888                (0, 3999),
1889            ],
1890            2,
1891            &[
1892                (0, true, vec![(0, 999), (1000, 1999)]),
1893                (2, true, vec![(0, 3999), (2000, 2999), (3000, 3999)]),
1894            ],
1895        );
1896
1897        check_assign_to_windows_with_overlapping(
1898            &[
1899                (0, 999),
1900                (1000, 1999),
1901                (2000, 2999),
1902                (3000, 3999),
1903                (1999, 3999),
1904            ],
1905            2,
1906            &[
1907                (0, true, vec![(0, 999), (1000, 1999)]),
1908                (2, true, vec![(1999, 3999), (2000, 2999), (3000, 3999)]),
1909            ],
1910        );
1911
1912        check_assign_to_windows_with_overlapping(
1913            &[
1914                (0, 999),     // window 0
1915                (1000, 1999), // window 0
1916                (2000, 2999), // window 2
1917                (3000, 3999), // window 2
1918                (2999, 3999), // window 2
1919            ],
1920            2,
1921            &[
1922                // Overlap within a window does not imply cross-window overlap.
1923                (0, false, vec![(0, 999), (1000, 1999)]),
1924                (2, false, vec![(2000, 2999), (2999, 3999), (3000, 3999)]),
1925            ],
1926        );
1927
1928        check_assign_to_windows_with_overlapping(
1929            &[
1930                (0, 999),     // window 0
1931                (1000, 1999), // window 0
1932                (2000, 2999), // window 2
1933                (3000, 3999), // window 2
1934                (0, 2000),    // window 2
1935            ],
1936            2,
1937            &[
1938                (0, true, vec![(0, 999), (1000, 1999)]),
1939                (2, true, vec![(0, 2000), (2000, 2999), (3000, 3999)]),
1940            ],
1941        );
1942    }
1943
1944    #[test]
1945    fn test_assign_to_windows_not_overlapping_when_pk_disjoint() {
1946        let files = [
1947            new_file_handle_with_size_sequence_and_primary_key_range(
1948                FileId::random(),
1949                0,
1950                1000,
1951                0,
1952                1,
1953                10,
1954                pk_range(b"a", b"f"),
1955            ),
1956            new_file_handle_with_size_sequence_and_primary_key_range(
1957                FileId::random(),
1958                500,
1959                2999,
1960                0,
1961                2,
1962                10,
1963                pk_range(b"x", b"z"),
1964            ),
1965        ];
1966
1967        let windows = assign_to_windows(files.iter(), 2);
1968
1969        assert_eq!(2, windows.len());
1970        let overlapping = window_has_overlap(windows.get(&0).unwrap(), &windows);
1971        assert!(!overlapping);
1972    }
1973
1974    #[test]
1975    fn test_assign_to_windows_pk_unknown_in_earlier_window_does_not_poison_later_windows() {
1976        let files = [
1977            new_file_handle(FileId::random(), 0, 1999, 0),
1978            new_file_handle_with_size_sequence_and_primary_key_range(
1979                FileId::random(),
1980                2000,
1981                3999,
1982                0,
1983                1,
1984                10,
1985                pk_range(b"a", b"f"),
1986            ),
1987            new_file_handle_with_size_sequence_and_primary_key_range(
1988                FileId::random(),
1989                3000,
1990                4999,
1991                0,
1992                2,
1993                10,
1994                pk_range(b"x", b"z"),
1995            ),
1996        ];
1997
1998        let windows = assign_to_windows(files.iter(), 2);
1999
2000        let overlapping = window_has_overlap(windows.get(&4).unwrap(), &windows);
2001        assert!(!overlapping);
2002    }
2003
2004    struct CompactionPickerTestCase {
2005        window_size: i64,
2006        input_files: Vec<FileHandle>,
2007        expected_outputs: Vec<ExpectedOutput>,
2008    }
2009
2010    impl CompactionPickerTestCase {
2011        async fn check(&self) {
2012            let file_id_to_idx = self
2013                .input_files
2014                .iter()
2015                .enumerate()
2016                .map(|(idx, file)| (file.file_id(), idx))
2017                .collect::<HashMap<_, _>>();
2018            let windows = assign_to_windows(self.input_files.iter(), self.window_size);
2019            let active_window =
2020                find_active_window_by_sequence(self.input_files.iter(), self.window_size).or_else(
2021                    || find_latest_window_in_seconds(self.input_files.iter(), self.window_size),
2022                );
2023            let output = TwcsPicker {
2024                trigger_file_num: 2,
2025                active_window_l1_merge_trigger: 8,
2026                inactive_window_trigger_file_num: 2,
2027                inactive_window_l1_merge_trigger: 2,
2028                time_window_seconds: None,
2029                max_output_file_size: None,
2030                append_mode: false,
2031                max_background_tasks: None,
2032                time_range: None,
2033            }
2034            .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
2035            .await
2036            .unwrap();
2037
2038            let output = output
2039                .iter()
2040                .map(|o| {
2041                    let input_file_ids = o
2042                        .inputs
2043                        .iter()
2044                        .map(|f| file_id_to_idx.get(&f.file_id()).copied().unwrap())
2045                        .collect::<HashSet<_>>();
2046                    (input_file_ids, o.output_level)
2047                })
2048                .collect::<Vec<_>>();
2049
2050            let expected = self
2051                .expected_outputs
2052                .iter()
2053                .map(|o| {
2054                    let input_file_ids = o.input_files.iter().copied().collect::<HashSet<_>>();
2055                    (input_file_ids, o.output_level)
2056                })
2057                .collect::<Vec<_>>();
2058            assert_eq!(expected, output);
2059        }
2060    }
2061
2062    struct ExpectedOutput {
2063        input_files: Vec<usize>,
2064        output_level: Level,
2065    }
2066
2067    #[tokio::test]
2068    async fn test_newer_windows_are_placed_last_for_execution() {
2069        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2070
2071        // Case 1: 2 runs found in each time window.
2072        CompactionPickerTestCase {
2073            window_size: 3,
2074            input_files: [
2075                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
2076                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
2077                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3), //active windows
2078                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4), //active windows
2079            ]
2080            .to_vec(),
2081            expected_outputs: vec![
2082                ExpectedOutput {
2083                    input_files: vec![0, 1],
2084                    output_level: 1,
2085                },
2086                ExpectedOutput {
2087                    input_files: vec![2, 3],
2088                    output_level: 1,
2089                },
2090            ],
2091        }
2092        .check()
2093        .await;
2094
2095        // Case 2:
2096        //    -2000........-3
2097        // -3000.....-100
2098        //                    0..............2999
2099        //                      50..........2998
2100        //                     11.........2990
2101        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
2102        CompactionPickerTestCase {
2103            window_size: 3,
2104            input_files: [
2105                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
2106                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
2107                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3),
2108                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4),
2109                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 5),
2110            ]
2111            .to_vec(),
2112            expected_outputs: vec![
2113                ExpectedOutput {
2114                    input_files: vec![0, 1],
2115                    output_level: 1,
2116                },
2117                ExpectedOutput {
2118                    input_files: vec![2, 3, 4],
2119                    output_level: 1,
2120                },
2121            ],
2122        }
2123        .check()
2124        .await;
2125
2126        // Case 3:
2127        // A compaction may split output into several files that have overlapping time ranges and
2128        // the same sequence. They are ordinary compaction candidates now: the picker merges the
2129        // overlapping ones in the window that reaches the trigger.
2130        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
2131        CompactionPickerTestCase {
2132            window_size: 3,
2133            input_files: [
2134                new_file_handle_with_sequence(file_ids[0], 0, 2999, 1, 1),
2135                new_file_handle_with_sequence(file_ids[1], 0, 2998, 1, 1),
2136                new_file_handle_with_sequence(file_ids[2], 3000, 5999, 1, 2),
2137                new_file_handle_with_sequence(file_ids[3], 3000, 5000, 1, 2),
2138                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 3),
2139            ]
2140            .to_vec(),
2141            expected_outputs: vec![ExpectedOutput {
2142                input_files: vec![2, 3],
2143                output_level: 1,
2144            }],
2145        }
2146        .check()
2147        .await;
2148    }
2149
2150    #[tokio::test]
2151    async fn test_build_output_skips_pk_disjoint_files() {
2152        let files = [
2153            new_file_handle_with_size_sequence_and_primary_key_range(
2154                FileId::random(),
2155                0,
2156                2999,
2157                0,
2158                1,
2159                10,
2160                pk_range(b"a", b"f"),
2161            ),
2162            new_file_handle_with_size_sequence_and_primary_key_range(
2163                FileId::random(),
2164                50,
2165                2998,
2166                0,
2167                2,
2168                10,
2169                pk_range(b"x", b"z"),
2170            ),
2171        ];
2172        let windows = assign_to_windows(files.iter(), 3);
2173        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2174        let output = TwcsPicker {
2175            trigger_file_num: 4,
2176            active_window_l1_merge_trigger: 8,
2177            inactive_window_trigger_file_num: 4,
2178            inactive_window_l1_merge_trigger: 8,
2179            time_window_seconds: None,
2180            max_output_file_size: None,
2181            append_mode: false,
2182            max_background_tasks: None,
2183            time_range: None,
2184        }
2185        .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
2186        .await
2187        .unwrap();
2188
2189        assert!(output.is_empty());
2190    }
2191
2192    #[test]
2193    fn test_append_mode_filter_large_files() {
2194        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2195        let max_output_file_size = 1000u64;
2196
2197        // Create files with different sizes
2198        let small_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 500);
2199        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 1500);
2200        let small_file_2 = new_file_handle_with_size_and_sequence(file_ids[2], 0, 999, 0, 3, 800);
2201        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[3], 0, 999, 0, 4, 2000);
2202
2203        let mut files_to_merge = vec![small_file_1, large_file_1, small_file_2, large_file_2];
2204
2205        // Test filtering logic directly
2206        let original_count = files_to_merge.len();
2207
2208        // Apply append mode filtering
2209        files_to_merge.retain(|file| file.size() <= max_output_file_size);
2210
2211        // Should have filtered out 2 large files, leaving 2 small files
2212        assert_eq!(files_to_merge.len(), 2);
2213        assert_eq!(original_count, 4);
2214
2215        // Verify the remaining files are the small ones
2216        for file in &files_to_merge {
2217            assert!(
2218                file.size() <= max_output_file_size,
2219                "File size {} should be <= {}",
2220                file.size(),
2221                max_output_file_size
2222            );
2223        }
2224    }
2225
2226    #[tokio::test]
2227    async fn test_build_output_multiple_windows_with_zero_runs() {
2228        let file_ids = (0..7).map(|_| FileId::random()).collect::<Vec<_>>();
2229
2230        let files = [
2231            // Window 0: Contains 3 files but not forming any runs (not enough files in sequence to reach trigger_file_num)
2232            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2233            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2234            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2235            // Window 3: Contains files that will form 2 runs
2236            new_file_handle_with_sequence(file_ids[3], 3000, 3999, 0, 4),
2237            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
2238            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
2239            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
2240        ];
2241
2242        let windows = assign_to_windows(files.iter(), 3);
2243
2244        // Create picker with trigger_file_num of 4 so single files won't form runs in first window
2245        let picker = TwcsPicker {
2246            trigger_file_num: 4, // High enough to prevent runs in first window
2247            active_window_l1_merge_trigger: 8,
2248            inactive_window_trigger_file_num: 4,
2249            inactive_window_l1_merge_trigger: 8,
2250            time_window_seconds: Some(3),
2251            max_output_file_size: None,
2252            append_mode: false,
2253            max_background_tasks: None,
2254            time_range: None,
2255        };
2256
2257        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2258        let output = picker
2259            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2260            .await
2261            .unwrap();
2262
2263        assert!(
2264            !output.is_empty(),
2265            "Should have output from windows with runs, even when one window has 0 runs"
2266        );
2267
2268        let all_output_files: Vec<_> = output
2269            .iter()
2270            .flat_map(|o| o.inputs.iter())
2271            .map(|f| f.file_id().file_id())
2272            .collect();
2273
2274        assert!(
2275            all_output_files.contains(&file_ids[3])
2276                || all_output_files.contains(&file_ids[4])
2277                || all_output_files.contains(&file_ids[5]),
2278            "Output should contain files from the window with runs"
2279        );
2280    }
2281
2282    #[tokio::test]
2283    async fn test_build_output_single_window_zero_runs() {
2284        let file_ids = (0..2).map(|_| FileId::random()).collect::<Vec<_>>();
2285
2286        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 2000); // 2000 bytes
2287        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 2500); // 2500 bytes
2288
2289        let files = [large_file_1, large_file_2];
2290
2291        let windows = assign_to_windows(files.iter(), 3);
2292
2293        let picker = TwcsPicker {
2294            trigger_file_num: 2,
2295            active_window_l1_merge_trigger: 8,
2296            inactive_window_trigger_file_num: 2,
2297            inactive_window_l1_merge_trigger: 8,
2298            time_window_seconds: Some(3),
2299            max_output_file_size: Some(1000),
2300            append_mode: true,
2301            max_background_tasks: None,
2302            time_range: None,
2303        };
2304
2305        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2306        let output = picker
2307            .build_output_with_time_range(RegionId::from_u64(456), windows, active_window, None)
2308            .await
2309            .unwrap();
2310
2311        // Should return empty output (no compaction needed)
2312        assert!(
2313            output.is_empty(),
2314            "Should return empty output when no runs are found after filtering"
2315        );
2316    }
2317
2318    #[tokio::test]
2319    async fn test_append_mode_can_pick_remaining_single_level_files() {
2320        let files = [
2321            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 100),
2322            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 100),
2323            new_file_handle_with_size_and_sequence(FileId::random(), 40, 49, 0, 3, 100),
2324            new_file_handle_with_size_and_sequence(FileId::random(), 60, 69, 0, 4, 2_000),
2325        ];
2326        let windows = assign_to_windows(files.iter(), 1);
2327        let picker = TwcsPicker {
2328            trigger_file_num: 4,
2329            active_window_l1_merge_trigger: 8,
2330            inactive_window_trigger_file_num: 4,
2331            inactive_window_l1_merge_trigger: 8,
2332            time_window_seconds: Some(1),
2333            max_output_file_size: Some(1_000),
2334            append_mode: true,
2335            max_background_tasks: None,
2336            time_range: None,
2337        };
2338
2339        let output = picker
2340            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
2341            .await
2342            .unwrap();
2343
2344        assert_eq!(1, output.len());
2345        assert_eq!(3, output[0].inputs.len());
2346    }
2347
2348    #[tokio::test]
2349    async fn test_max_background_tasks_truncation() {
2350        let file_ids = (0..10).map(|_| FileId::random()).collect::<Vec<_>>();
2351        let max_background_tasks = 3;
2352
2353        // Create files across multiple windows that will generate multiple compaction outputs
2354        let files = [
2355            // Window 0: 4 files that will form a run
2356            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2357            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2358            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2359            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
2360            // Window 3: 4 files that will form another run
2361            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
2362            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
2363            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
2364            new_file_handle_with_sequence(file_ids[7], 3000, 3999, 0, 8),
2365            // Window 6: 4 files that will form another run
2366            new_file_handle_with_sequence(file_ids[8], 6000, 6999, 0, 9),
2367            new_file_handle_with_sequence(file_ids[9], 6000, 6999, 0, 10),
2368        ];
2369
2370        let windows = assign_to_windows(files.iter(), 3);
2371
2372        let picker = TwcsPicker {
2373            trigger_file_num: 4,
2374            active_window_l1_merge_trigger: 8,
2375            inactive_window_trigger_file_num: 4,
2376            inactive_window_l1_merge_trigger: 8,
2377            time_window_seconds: Some(3),
2378            max_output_file_size: None,
2379            append_mode: false,
2380            max_background_tasks: Some(max_background_tasks),
2381            time_range: None,
2382        };
2383
2384        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2385        let output = picker
2386            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2387            .await
2388            .unwrap();
2389
2390        // Should have at most max_background_tasks outputs
2391        assert!(
2392            output.len() <= max_background_tasks,
2393            "Output should be truncated to max_background_tasks: expected <= {}, got {}",
2394            max_background_tasks,
2395            output.len()
2396        );
2397
2398        // Without max_background_tasks, should have more outputs
2399        let picker_no_limit = TwcsPicker {
2400            trigger_file_num: 4,
2401            active_window_l1_merge_trigger: 8,
2402            inactive_window_trigger_file_num: 4,
2403            inactive_window_l1_merge_trigger: 8,
2404            time_window_seconds: Some(3),
2405            max_output_file_size: None,
2406            append_mode: false,
2407            max_background_tasks: None,
2408            time_range: None,
2409        };
2410
2411        let windows_no_limit = assign_to_windows(files.iter(), 3);
2412        let output_no_limit = picker_no_limit
2413            .build_output_with_time_range(
2414                RegionId::from_u64(123),
2415                windows_no_limit,
2416                active_window,
2417                None,
2418            )
2419            .await
2420            .unwrap();
2421
2422        // Without limit, should have more outputs (if there are enough windows)
2423        if output_no_limit.len() > max_background_tasks {
2424            assert!(
2425                output_no_limit.len() > output.len(),
2426                "Without limit should have more outputs than with limit"
2427            );
2428        }
2429    }
2430
2431    #[tokio::test]
2432    async fn test_max_background_tasks_no_truncation_when_under_limit() {
2433        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2434        let max_background_tasks = 10; // Larger than expected outputs
2435
2436        // Create files in one window that will generate one compaction output
2437        let files = [
2438            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2439            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2440            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2441            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
2442        ];
2443
2444        let windows = assign_to_windows(files.iter(), 3);
2445
2446        let picker = TwcsPicker {
2447            trigger_file_num: 4,
2448            active_window_l1_merge_trigger: 8,
2449            inactive_window_trigger_file_num: 4,
2450            inactive_window_l1_merge_trigger: 8,
2451            time_window_seconds: Some(3),
2452            max_output_file_size: None,
2453            append_mode: false,
2454            max_background_tasks: Some(max_background_tasks),
2455            time_range: None,
2456        };
2457
2458        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2459        let output = picker
2460            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2461            .await
2462            .unwrap();
2463
2464        // Should have all outputs since we're under the limit
2465        assert!(
2466            output.len() <= max_background_tasks,
2467            "Output should be within limit"
2468        );
2469        // Should have at least one output
2470        assert!(!output.is_empty(), "Should have at least one output");
2471    }
2472
2473    #[tokio::test]
2474    async fn test_pick_multiple_runs() {
2475        common_telemetry::init_default_ut_logging();
2476
2477        let num_files = 8;
2478        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
2479
2480        // Create files with different sequences so they form multiple runs
2481        let files: Vec<_> = file_ids
2482            .iter()
2483            .enumerate()
2484            .map(|(idx, file_id)| {
2485                new_file_handle_with_size_and_sequence(
2486                    *file_id,
2487                    0,
2488                    999,
2489                    0,
2490                    (idx + 1) as u64,
2491                    1024 * 1024,
2492                )
2493            })
2494            .collect();
2495
2496        let windows = assign_to_windows(files.iter(), 3);
2497
2498        let picker = TwcsPicker {
2499            trigger_file_num: 4,
2500            active_window_l1_merge_trigger: 8,
2501            inactive_window_trigger_file_num: 4,
2502            inactive_window_l1_merge_trigger: 8,
2503            time_window_seconds: Some(3),
2504            max_output_file_size: None,
2505            append_mode: false,
2506            max_background_tasks: None,
2507            time_range: None,
2508        };
2509
2510        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2511        let output = picker
2512            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2513            .await
2514            .unwrap();
2515
2516        assert_eq!(1, output.len());
2517        assert_eq!(output[0].inputs.len(), num_files);
2518    }
2519
2520    #[tokio::test]
2521    async fn test_window_trigger_can_exceed_input_limit() {
2522        common_telemetry::init_default_ut_logging();
2523
2524        let num_files = 50;
2525        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
2526
2527        // Create files with different sequences so they form 2 runs
2528        let files: Vec<_> = file_ids
2529            .iter()
2530            .enumerate()
2531            .map(|(idx, file_id)| {
2532                new_file_handle_with_size_and_sequence(
2533                    *file_id,
2534                    (idx / 2 * 10) as i64,
2535                    (idx / 2 * 10 + 5) as i64,
2536                    0,
2537                    (idx + 1) as u64,
2538                    1024 * 1024,
2539                )
2540            })
2541            .collect();
2542
2543        let windows = assign_to_windows(files.iter(), 3);
2544
2545        let picker = TwcsPicker {
2546            trigger_file_num: num_files,
2547            active_window_l1_merge_trigger: 8,
2548            inactive_window_trigger_file_num: num_files,
2549            inactive_window_l1_merge_trigger: 8,
2550            time_window_seconds: Some(3),
2551            max_output_file_size: None,
2552            append_mode: false,
2553            max_background_tasks: None,
2554            time_range: None,
2555        };
2556
2557        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2558        let output = picker
2559            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2560            .await
2561            .unwrap();
2562
2563        assert_eq!(1, output.len());
2564        assert_eq!(output[0].inputs.len(), num_files.min(*MAX_INPUT_FILES));
2565    }
2566
2567    #[tokio::test]
2568    async fn test_limit_max_input_files_keeps_deletion_markers() {
2569        common_telemetry::init_default_ut_logging();
2570
2571        // One large file group spanning the whole window plus enough small ones to fill the
2572        // input limit. That is exactly 2 runs, so the window allows filtering deletions.
2573        let mut files = vec![new_file_handle_with_size_and_sequence(
2574            FileId::random(),
2575            0,
2576            3_000_000,
2577            0,
2578            1,
2579            1024 * 1024 * 1024,
2580        )];
2581        files.extend((0..DEFAULT_MAX_INPUT_FILES as i64).map(|idx| {
2582            new_file_handle_with_size_and_sequence(
2583                FileId::random(),
2584                (idx + 1) * 10_000,
2585                (idx + 1) * 10_000 + 1_000,
2586                0,
2587                (idx + 2) as u64,
2588                1024,
2589            )
2590        }));
2591
2592        let windows = assign_to_windows(files.iter(), 3600);
2593
2594        let picker = TwcsPicker {
2595            trigger_file_num: 4,
2596            active_window_l1_merge_trigger: 8,
2597            inactive_window_trigger_file_num: 4,
2598            inactive_window_l1_merge_trigger: 8,
2599            time_window_seconds: Some(3600),
2600            max_output_file_size: None,
2601            append_mode: false,
2602            max_background_tasks: None,
2603            time_range: None,
2604        };
2605
2606        let active_window = find_latest_window_in_seconds(files.iter(), 3600);
2607        let output = picker
2608            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2609            .await
2610            .unwrap();
2611
2612        assert_eq!(1, output.len());
2613        // The input file num limit picks the smallest groups first, so the large group is
2614        // left behind while the small groups that overlap it are compacted.
2615        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2616        assert!(
2617            !output[0].filter_deleted,
2618            "deletion markers must be kept once the file num limit drops files they may mask"
2619        );
2620    }
2621
2622    #[tokio::test]
2623    async fn test_limit_max_input_files_still_filters_without_overlap() {
2624        common_telemetry::init_default_ut_logging();
2625
2626        // More disjoint file groups than the input limit, i.e. a single run. Nothing left
2627        // behind can hold a row masked by a deletion marker we compact.
2628        let files: Vec<_> = (0..DEFAULT_MAX_INPUT_FILES as i64 + 8)
2629            .map(|idx| {
2630                new_file_handle_with_size_and_sequence(
2631                    FileId::random(),
2632                    (idx + 1) * 10_000,
2633                    (idx + 1) * 10_000 + 1_000,
2634                    0,
2635                    (idx + 1) as u64,
2636                    1024,
2637                )
2638            })
2639            .collect();
2640
2641        let windows = assign_to_windows(files.iter(), 3600);
2642
2643        let picker = TwcsPicker {
2644            trigger_file_num: 4,
2645            active_window_l1_merge_trigger: 8,
2646            inactive_window_trigger_file_num: 4,
2647            inactive_window_l1_merge_trigger: 8,
2648            time_window_seconds: Some(3600),
2649            max_output_file_size: Some(1024 * 1024 * 1024),
2650            append_mode: false,
2651            max_background_tasks: None,
2652            time_range: None,
2653        };
2654
2655        let active_window = find_latest_window_in_seconds(files.iter(), 3600);
2656        let output = picker
2657            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2658            .await
2659            .unwrap();
2660
2661        assert_eq!(1, output.len());
2662        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2663        assert!(output[0].filter_deleted);
2664    }
2665
2666    #[tokio::test]
2667    async fn test_newer_windows_have_priority() {
2668        let older_file_ids = [FileId::random(), FileId::random()];
2669        let newer_file_ids = [FileId::random(), FileId::random()];
2670        let files = [
2671            new_file_handle_with_sequence(older_file_ids[0], 1_000, 1_999, 0, 1),
2672            new_file_handle_with_sequence(older_file_ids[1], 1_000, 1_999, 0, 2),
2673            new_file_handle_with_sequence(newer_file_ids[0], 7_000, 7_999, 0, 3),
2674            new_file_handle_with_sequence(newer_file_ids[1], 7_000, 7_999, 0, 4),
2675        ];
2676        let windows = assign_to_windows(files.iter(), 3);
2677        let picker = TwcsPicker {
2678            trigger_file_num: 2,
2679            active_window_l1_merge_trigger: 8,
2680            inactive_window_trigger_file_num: 2,
2681            inactive_window_l1_merge_trigger: 8,
2682            time_window_seconds: Some(3),
2683            max_output_file_size: None,
2684            append_mode: false,
2685            max_background_tasks: Some(1),
2686            time_range: None,
2687        };
2688
2689        let output = picker
2690            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(6), None)
2691            .await
2692            .unwrap();
2693
2694        assert_eq!(1, output.len());
2695        assert_eq!(
2696            newer_file_ids.into_iter().collect::<HashSet<_>>(),
2697            output[0]
2698                .inputs
2699                .iter()
2700                .map(|file| file.file_id().file_id())
2701                .collect::<HashSet<_>>()
2702        );
2703    }
2704
2705    #[test]
2706    fn test_filter_time_windows_by_time_range() {
2707        // Interior, exclusive boundary ends, fractional boundary ends, negative
2708        // timestamps, and empty ranges must all use the same half-open windows.
2709        for (start, end, expected_windows) in [
2710            (1_200, 1_800, vec![0]),
2711            (0, 3_000, vec![0]),
2712            (3_000, 6_000, vec![3]),
2713            (2_999, 3_000, vec![0]),
2714            (2_999, 3_001, vec![0, 3]),
2715            (3_000, 3_001, vec![3]),
2716            (3_001, 3_002, vec![3]),
2717            (-3_000, 0, vec![-3]),
2718            (-3_001, -2_999, vec![-6, -3]),
2719            (0, 0, vec![]),
2720            (1_500, 1_500, vec![]),
2721        ] {
2722            let time_range = TimestampRange::with_unit(start, end, TimeUnit::Millisecond).unwrap();
2723            let selected = [-6, -3, 0, 3, 6]
2724                .into_iter()
2725                .filter(|window| time_window_intersects_range(*window, 3, &time_range))
2726                .collect::<Vec<_>>();
2727            assert_eq!(expected_windows, selected, "{time_range}");
2728        }
2729    }
2730
2731    #[test]
2732    fn test_filter_time_windows_with_unbounded_and_extreme_ranges() {
2733        for (range, expected_windows) in [
2734            (TimestampRange::min_to_max(), vec![-3, 0, 3]),
2735            (
2736                TimestampRange::from_start(Timestamp::new_second(0)),
2737                vec![0, 3],
2738            ),
2739            (
2740                TimestampRange::until_end(Timestamp::new_second(0), false),
2741                vec![-3],
2742            ),
2743        ] {
2744            let selected = [-3, 0, 3]
2745                .into_iter()
2746                .filter(|window| time_window_intersects_range(*window, 3, &range))
2747                .collect::<Vec<_>>();
2748            assert_eq!(expected_windows, selected, "{range}");
2749        }
2750
2751        let upper_range = TimestampRange::new(
2752            Timestamp::new_second(i64::MAX - 1),
2753            Timestamp::new_second(i64::MAX),
2754        )
2755        .unwrap();
2756        assert!(!time_window_intersects_range(0, 4, &upper_range));
2757        assert!(time_window_intersects_range(i64::MAX - 3, 4, &upper_range));
2758
2759        let lower_range = TimestampRange::until_end(Timestamp::new_second(i64::MIN), false);
2760        assert!(!time_window_intersects_range(i64::MIN, 3, &lower_range));
2761    }
2762
2763    #[tokio::test]
2764    async fn test_time_range_filter_precedes_background_task_limit() {
2765        let early_file_ids = [FileId::random(), FileId::random()];
2766        let selected_file_ids = [FileId::random(), FileId::random()];
2767        let files = [
2768            new_file_handle_with_sequence(early_file_ids[0], 1_000, 1_999, 0, 1),
2769            new_file_handle_with_sequence(early_file_ids[1], 1_000, 1_999, 0, 2),
2770            new_file_handle_with_sequence(selected_file_ids[0], 7_000, 7_999, 0, 3),
2771            new_file_handle_with_sequence(selected_file_ids[1], 7_000, 7_999, 0, 4),
2772        ];
2773        let windows = assign_to_windows(files.iter(), 3);
2774        let picker = TwcsPicker {
2775            trigger_file_num: 2,
2776            active_window_l1_merge_trigger: 8,
2777            inactive_window_trigger_file_num: 2,
2778            inactive_window_l1_merge_trigger: 8,
2779            time_window_seconds: Some(3),
2780            max_output_file_size: None,
2781            append_mode: false,
2782            max_background_tasks: Some(1),
2783            time_range: TimestampRange::new(
2784                Timestamp::new_millisecond(7_200),
2785                Timestamp::new_millisecond(7_800),
2786            ),
2787        };
2788
2789        let output = picker
2790            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(6), Some(3))
2791            .await
2792            .unwrap();
2793
2794        assert_eq!(1, output.len());
2795        assert_eq!(
2796            selected_file_ids.into_iter().collect::<HashSet<_>>(),
2797            output[0]
2798                .inputs
2799                .iter()
2800                .map(|file| file.file_id().file_id())
2801                .collect::<HashSet<_>>()
2802        );
2803    }
2804
2805    #[tokio::test]
2806    async fn test_inactive_window_uses_its_trigger_file_num() {
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        ];
2811        let windows = assign_to_windows(files.iter(), 100);
2812        let picker = TwcsPicker {
2813            trigger_file_num: 4,
2814            active_window_l1_merge_trigger: 8,
2815            inactive_window_trigger_file_num: 2,
2816            inactive_window_l1_merge_trigger: 8,
2817            time_window_seconds: Some(100),
2818            max_output_file_size: None,
2819            append_mode: false,
2820            max_background_tasks: None,
2821            time_range: None,
2822        };
2823
2824        let output = picker
2825            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
2826            .await
2827            .unwrap();
2828
2829        assert_eq!(1, output.len());
2830        assert_eq!(2, output[0].inputs.len());
2831    }
2832
2833    #[test]
2834    fn test_inactive_window_l1_trigger_is_independent_of_l0_trigger() {
2835        let l0_files = (0..6)
2836            .map(|idx| {
2837                new_file_handle_with_size_and_sequence(
2838                    FileId::random(),
2839                    idx * 20,
2840                    idx * 20 + 10,
2841                    0,
2842                    idx as u64 + 1,
2843                    10,
2844                )
2845            })
2846            .collect();
2847        let l1_files = (0..2)
2848            .map(|idx| {
2849                new_file_handle_with_size_and_sequence(
2850                    FileId::random(),
2851                    idx * 20,
2852                    idx * 20 + 10,
2853                    1,
2854                    idx as u64 + 10,
2855                    10,
2856                )
2857            })
2858            .collect();
2859
2860        let (inputs, _) = pick_inactive_window_files(
2861            l0_files,
2862            l1_files,
2863            InactiveWindowPick {
2864                l0_file_num: 8,
2865                l1_file_num: 2,
2866                phase: PickPhase::L1FileReduction,
2867                primary_key_mapper: &primary_key_mapper_for_test(),
2868            },
2869            None,
2870        );
2871
2872        assert_eq!(2, inputs.len());
2873        assert!(inputs.iter().all(|file| file.level() == 1));
2874    }
2875
2876    #[test]
2877    fn test_inactive_window_does_not_fallback_to_l1_below_trigger() {
2878        let l1_files = (0..2)
2879            .map(|idx| {
2880                new_file_handle_with_size_and_sequence(
2881                    FileId::random(),
2882                    idx * 20,
2883                    idx * 20 + 10,
2884                    1,
2885                    idx as u64 + 1,
2886                    10,
2887                )
2888            })
2889            .collect();
2890
2891        let (inputs, _) = pick_inactive_window_files(
2892            vec![],
2893            l1_files,
2894            InactiveWindowPick {
2895                l0_file_num: 2,
2896                l1_file_num: 8,
2897                phase: PickPhase::L1FileReduction,
2898                primary_key_mapper: &primary_key_mapper_for_test(),
2899            },
2900            None,
2901        );
2902
2903        assert!(inputs.is_empty());
2904    }
2905
2906    #[tokio::test]
2907    async fn test_inactive_window_requires_a_level_trigger_before_mixed_fallback() {
2908        let files = [
2909            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2910            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 1, 2, 10),
2911        ];
2912        let windows = assign_to_windows(files.iter(), 100);
2913        let picker = TwcsPicker {
2914            trigger_file_num: 4,
2915            active_window_l1_merge_trigger: 8,
2916            inactive_window_trigger_file_num: 8,
2917            inactive_window_l1_merge_trigger: 2,
2918            time_window_seconds: Some(100),
2919            max_output_file_size: Some(1_000),
2920            append_mode: false,
2921            max_background_tasks: None,
2922            time_range: None,
2923        };
2924
2925        let output = picker
2926            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
2927            .await
2928            .unwrap();
2929
2930        assert!(output.is_empty());
2931    }
2932
2933    #[tokio::test]
2934    async fn test_count_first_prefers_more_files_over_smaller_overlap() {
2935        let files = [
2936            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2937            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
2938            new_file_handle_with_size_and_sequence(FileId::random(), 40, 50, 0, 3, 10),
2939            new_file_handle_with_size_and_sequence(FileId::random(), 5, 15, 0, 4, 10),
2940        ];
2941        let windows = assign_to_windows(files.iter(), 100);
2942        let picker = TwcsPicker {
2943            trigger_file_num: 2,
2944            active_window_l1_merge_trigger: 8,
2945            inactive_window_trigger_file_num: 2,
2946            inactive_window_l1_merge_trigger: 8,
2947            time_window_seconds: Some(100),
2948            max_output_file_size: None,
2949            append_mode: false,
2950            max_background_tasks: None,
2951            time_range: None,
2952        };
2953
2954        let output = picker
2955            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
2956            .await
2957            .unwrap();
2958
2959        assert_eq!(1, output.len());
2960        assert_eq!(4, output[0].inputs.len());
2961    }
2962
2963    #[tokio::test]
2964    async fn test_count_first_trigger_counts_physical_ssts() {
2965        let files = [
2966            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2967            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2968            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
2969        ];
2970        let windows = assign_to_windows(files.iter(), 100);
2971        let picker = TwcsPicker {
2972            trigger_file_num: 3,
2973            active_window_l1_merge_trigger: 8,
2974            inactive_window_trigger_file_num: 3,
2975            inactive_window_l1_merge_trigger: 8,
2976            time_window_seconds: Some(100),
2977            max_output_file_size: None,
2978            append_mode: false,
2979            max_background_tasks: None,
2980            time_range: None,
2981        };
2982
2983        let output = picker
2984            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
2985            .await
2986            .unwrap();
2987
2988        assert_eq!(1, output.len());
2989        assert_eq!(3, output[0].inputs.len());
2990    }
2991
2992    #[tokio::test]
2993    async fn test_count_first_does_not_compact_overlap_below_trigger() {
2994        let files = [
2995            new_file_handle_with_size_and_sequence(FileId::random(), 0, 20, 0, 1, 10),
2996            new_file_handle_with_size_and_sequence(FileId::random(), 10, 30, 0, 2, 10),
2997        ];
2998        let windows = assign_to_windows(files.iter(), 100);
2999        let picker = TwcsPicker {
3000            trigger_file_num: 3,
3001            active_window_l1_merge_trigger: 8,
3002            inactive_window_trigger_file_num: 3,
3003            inactive_window_l1_merge_trigger: 8,
3004            time_window_seconds: Some(100),
3005            max_output_file_size: None,
3006            append_mode: false,
3007            max_background_tasks: None,
3008            time_range: None,
3009        };
3010
3011        let output = picker
3012            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3013            .await
3014            .unwrap();
3015
3016        assert!(output.is_empty());
3017    }
3018
3019    #[tokio::test]
3020    async fn test_filter_deleted_is_false_when_selected_files_overlap_unselected_file() {
3021        let mut files = (0..32)
3022            .map(|idx| {
3023                new_file_handle_with_size_and_sequence(
3024                    FileId::random(),
3025                    idx * 10,
3026                    idx * 10 + 9,
3027                    0,
3028                    idx as u64 + 1,
3029                    10,
3030                )
3031            })
3032            .collect::<Vec<_>>();
3033        files.push(new_file_handle_with_size_and_sequence(
3034            FileId::random(),
3035            0,
3036            320,
3037            0,
3038            33,
3039            10,
3040        ));
3041        let windows = assign_to_windows(files.iter(), 1000);
3042        let picker = TwcsPicker {
3043            trigger_file_num: 2,
3044            active_window_l1_merge_trigger: 8,
3045            inactive_window_trigger_file_num: 2,
3046            inactive_window_l1_merge_trigger: 8,
3047            time_window_seconds: Some(1000),
3048            max_output_file_size: None,
3049            append_mode: false,
3050            max_background_tasks: None,
3051            time_range: None,
3052        };
3053
3054        let output = picker
3055            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3056            .await
3057            .unwrap();
3058
3059        assert_eq!(1, output.len());
3060        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
3061        assert!(!output[0].filter_deleted);
3062    }
3063
3064    fn new_file(start: i64, end: i64, sequence: u64, file_size: u64) -> FileHandle {
3065        new_file_handle_with_size_and_sequence(FileId::random(), start, end, 0, sequence, file_size)
3066    }
3067
3068    fn new_file_with_level_and_rows(
3069        start: i64,
3070        end: i64,
3071        level: Level,
3072        sequence: u64,
3073        file_size: u64,
3074        num_rows: u64,
3075    ) -> FileHandle {
3076        let file = new_file_handle_with_size_and_sequence(
3077            FileId::random(),
3078            start,
3079            end,
3080            level,
3081            sequence,
3082            file_size,
3083        );
3084        let mut meta = file.meta_ref().clone();
3085        meta.num_rows = num_rows;
3086        FileHandle::new(meta, crate::test_util::new_noop_file_purger())
3087    }
3088
3089    fn picked_ranges(files: &[FileHandle]) -> Vec<(i64, i64)> {
3090        files
3091            .iter()
3092            .map(|file| {
3093                let (start, end) = file.range();
3094                (start.value(), end.value())
3095            })
3096            .collect()
3097    }
3098
3099    #[test]
3100    fn test_count_first_rejects_dominant_historical_file() {
3101        let picked = pick_count_first(
3102            vec![SortedRun::from(vec![
3103                new_file(0, 9, 1, 400),
3104                new_file(20, 29, 2, 100),
3105            ])],
3106            None,
3107        );
3108
3109        assert!(picked.is_empty());
3110    }
3111
3112    #[test]
3113    fn test_count_first_accepts_balanced_historical_file() {
3114        let picked = pick_count_first(
3115            vec![SortedRun::from(vec![
3116                new_file(0, 9, 1, 400),
3117                new_file(20, 29, 2, 100),
3118                new_file(40, 49, 3, 100),
3119                new_file(60, 69, 4, 100),
3120                new_file(80, 89, 5, 100),
3121            ])],
3122            None,
3123        );
3124
3125        assert_eq!(
3126            vec![(0, 9), (20, 29), (40, 49), (60, 69), (80, 89)],
3127            picked_ranges(&picked)
3128        );
3129    }
3130
3131    #[test]
3132    fn test_count_first_finds_smaller_balanced_interval_when_larger_one_is_unbalanced() {
3133        let picked = pick_count_first(
3134            vec![SortedRun::from(vec![
3135                new_file(0, 9, 1, 1000),
3136                new_file(20, 29, 2, 100),
3137                new_file(40, 49, 3, 100),
3138                new_file(60, 69, 4, 100),
3139                new_file(80, 89, 5, 100),
3140            ])],
3141            None,
3142        );
3143
3144        assert_eq!(
3145            vec![(20, 29), (40, 49), (60, 69), (80, 89)],
3146            picked_ranges(&picked)
3147        );
3148    }
3149
3150    #[test]
3151    fn test_count_first_prefers_overlap_participants_when_file_counts_match() {
3152        let first_run = (0..DEFAULT_MAX_INPUT_FILES)
3153            .map(|idx| {
3154                let start = idx as i64 * 20;
3155                let end = if idx + 1 == DEFAULT_MAX_INPUT_FILES {
3156                    700
3157                } else {
3158                    start + 9
3159                };
3160                new_file(start, end, idx as u64 + 1, 10)
3161            })
3162            .collect::<Vec<_>>();
3163        let overlapping = new_file(690, 710, 100, 10);
3164
3165        let picked = pick_count_first(
3166            vec![
3167                SortedRun::from(first_run),
3168                SortedRun::from(vec![overlapping]),
3169            ],
3170            None,
3171        );
3172        let ranges = picked_ranges(&picked);
3173
3174        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3175        assert!(!ranges.contains(&(0, 9)));
3176        assert!(ranges.contains(&(690, 710)));
3177    }
3178
3179    #[tokio::test]
3180    async fn test_picker_avoids_chained_l1_rewrites_by_compacting_levels_separately() {
3181        let mut enough_l0 = (0..DEFAULT_MAX_INPUT_FILES)
3182            .map(|idx| {
3183                let start = idx as i64 * 20;
3184                new_file_handle_with_size_and_sequence(
3185                    FileId::random(),
3186                    start,
3187                    start + 9,
3188                    0,
3189                    idx as u64 + 1,
3190                    10,
3191                )
3192            })
3193            .collect::<Vec<_>>();
3194        enough_l0.push(new_file_handle_with_size_and_sequence(
3195            FileId::random(),
3196            0,
3197            700,
3198            1,
3199            100,
3200            100,
3201        ));
3202        let mut enough_l1 = (0..4)
3203            .map(|idx| {
3204                new_file_handle_with_size_and_sequence(
3205                    FileId::random(),
3206                    idx * 100,
3207                    idx * 100 + 99,
3208                    1,
3209                    idx as u64 + 1,
3210                    100,
3211                )
3212            })
3213            .collect::<Vec<_>>();
3214        enough_l1.extend((0..3).map(|idx| {
3215            new_file_handle_with_size_and_sequence(
3216                FileId::random(),
3217                idx * 20,
3218                idx * 20 + 9,
3219                0,
3220                idx as u64 + 10,
3221                10,
3222            )
3223        }));
3224        let picker = TwcsPicker {
3225            trigger_file_num: 4,
3226            active_window_l1_merge_trigger: 8,
3227            inactive_window_trigger_file_num: 4,
3228            inactive_window_l1_merge_trigger: 4,
3229            time_window_seconds: Some(1),
3230            max_output_file_size: None,
3231            append_mode: false,
3232            max_background_tasks: None,
3233            time_range: None,
3234        };
3235
3236        for (case, files, expected_level, expected_len) in [
3237            ("L0 reaches trigger", enough_l0, 0, DEFAULT_MAX_INPUT_FILES),
3238            ("L0 fallback precedes triggered L1", enough_l1, 0, 3),
3239        ] {
3240            let windows = assign_to_windows(files.iter(), 1);
3241            let output = picker
3242                .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3243                .await
3244                .unwrap();
3245
3246            assert_eq!(1, output.len(), "{case}");
3247            assert_eq!(expected_len, output[0].inputs.len(), "{case}");
3248            assert!(
3249                output[0]
3250                    .inputs
3251                    .iter()
3252                    .all(|file| file.level() == expected_level),
3253                "{case}"
3254            );
3255        }
3256    }
3257
3258    #[tokio::test]
3259    async fn test_active_window_defers_l1_below_safety_trigger() {
3260        let files = (0..7)
3261            .map(|idx| {
3262                let start = idx * 100;
3263                new_file_handle_with_size_and_sequence(
3264                    FileId::random(),
3265                    start,
3266                    start + 99,
3267                    1,
3268                    idx as u64 + 1,
3269                    100,
3270                )
3271            })
3272            .collect::<Vec<_>>();
3273        let windows = assign_to_windows(files.iter(), 1);
3274        let active_window = windows.keys().next().copied();
3275        let picker = TwcsPicker {
3276            trigger_file_num: 4,
3277            active_window_l1_merge_trigger: 8,
3278            inactive_window_trigger_file_num: 2,
3279            inactive_window_l1_merge_trigger: 8,
3280            time_window_seconds: Some(1),
3281            max_output_file_size: None,
3282            append_mode: false,
3283            max_background_tasks: None,
3284            time_range: None,
3285        };
3286
3287        let output = picker
3288            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3289            .await
3290            .unwrap();
3291
3292        assert!(output.is_empty());
3293    }
3294
3295    #[tokio::test]
3296    async fn test_active_window_l1_safety_trigger_is_independent_of_l0_trigger() {
3297        let files = (0..8)
3298            .map(|idx| {
3299                let start = idx * 100;
3300                new_file_handle_with_size_and_sequence(
3301                    FileId::random(),
3302                    start,
3303                    start + 99,
3304                    1,
3305                    idx as u64 + 1,
3306                    100,
3307                )
3308            })
3309            .collect::<Vec<_>>();
3310        let windows = assign_to_windows(files.iter(), 1);
3311        let active_window = windows.keys().next().copied();
3312        let picker = TwcsPicker {
3313            trigger_file_num: 16,
3314            active_window_l1_merge_trigger: 8,
3315            inactive_window_trigger_file_num: 2,
3316            inactive_window_l1_merge_trigger: 8,
3317            time_window_seconds: Some(1),
3318            max_output_file_size: None,
3319            append_mode: false,
3320            max_background_tasks: None,
3321            time_range: None,
3322        };
3323
3324        let output = picker
3325            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3326            .await
3327            .unwrap();
3328
3329        assert_eq!(1, output.len());
3330        assert_eq!(8, output[0].inputs.len());
3331        assert!(output[0].inputs.iter().all(|file| file.level() == 1));
3332    }
3333
3334    #[tokio::test]
3335    async fn test_picker_falls_back_to_l1_when_triggered_l0_cannot_make_progress() {
3336        let mut files = (0..4)
3337            .map(|idx| {
3338                let start = idx * 20;
3339                new_file_handle_with_size_and_sequence(
3340                    FileId::random(),
3341                    start,
3342                    start + 9,
3343                    0,
3344                    idx as u64 + 1,
3345                    600,
3346                )
3347            })
3348            .collect::<Vec<_>>();
3349        files.extend((0..8).map(|idx| {
3350            let start = idx * 20 + 100;
3351            new_file_handle_with_size_and_sequence(
3352                FileId::random(),
3353                start,
3354                start + 9,
3355                1,
3356                idx as u64 + 10,
3357                50,
3358            )
3359        }));
3360        let windows = assign_to_windows(files.iter(), 1);
3361        let active_window = windows.keys().next().copied();
3362        let picker = TwcsPicker {
3363            trigger_file_num: 4,
3364            active_window_l1_merge_trigger: 8,
3365            inactive_window_trigger_file_num: 4,
3366            inactive_window_l1_merge_trigger: 8,
3367            time_window_seconds: Some(1),
3368            max_output_file_size: Some(512),
3369            append_mode: false,
3370            max_background_tasks: None,
3371            time_range: None,
3372        };
3373
3374        let output = picker
3375            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3376            .await
3377            .unwrap();
3378
3379        assert_eq!(1, output.len());
3380        assert_eq!(8, output[0].inputs.len());
3381        assert!(output[0].inputs.iter().all(|file| file.level() == 1));
3382    }
3383
3384    fn priority_test_files(
3385        window: i64,
3386        level: Level,
3387        file_size: u64,
3388        overlap: bool,
3389    ) -> [FileHandle; 2] {
3390        let start = window * 1_000;
3391        let ranges = if overlap {
3392            [(start, start + 499), (start + 250, start + 749)]
3393        } else {
3394            [(start, start + 99), (start + 200, start + 299)]
3395        };
3396        ranges.map(|(start, end)| {
3397            new_file_handle_with_size_and_sequence(
3398                FileId::random(),
3399                start,
3400                end,
3401                level,
3402                window as u64 + 1,
3403                file_size,
3404            )
3405        })
3406    }
3407
3408    fn priority_test_picker(max_background_tasks: usize) -> TwcsPicker {
3409        TwcsPicker {
3410            trigger_file_num: 2,
3411            active_window_l1_merge_trigger: 2,
3412            inactive_window_trigger_file_num: 2,
3413            inactive_window_l1_merge_trigger: 2,
3414            time_window_seconds: Some(1),
3415            max_output_file_size: Some(100),
3416            append_mode: false,
3417            max_background_tasks: Some(max_background_tasks),
3418            time_range: None,
3419        }
3420    }
3421
3422    fn output_window(output: &CompactionOutput) -> i64 {
3423        output.inputs[0]
3424            .time_range()
3425            .1
3426            .convert_to(TimeUnit::Second)
3427            .unwrap()
3428            .value()
3429    }
3430
3431    #[tokio::test]
3432    async fn test_older_l0_candidate_has_priority_over_newer_pure_l1_candidate() {
3433        let files = priority_test_files(0, 0, 40, false)
3434            .into_iter()
3435            .chain(priority_test_files(1, 1, 40, false))
3436            .collect::<Vec<_>>();
3437        let windows = assign_to_windows(files.iter(), 1);
3438
3439        let output = priority_test_picker(1)
3440            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3441            .await
3442            .unwrap();
3443
3444        assert_eq!(1, output.len());
3445        assert_eq!(0, output_window(&output[0]));
3446        assert!(output[0].inputs.iter().all(|file| file.level() == 0));
3447    }
3448
3449    #[tokio::test]
3450    async fn test_older_l1_file_reduction_has_priority_over_newer_overlap_only_l1() {
3451        let files = priority_test_files(0, 1, 40, false)
3452            .into_iter()
3453            .chain(priority_test_files(1, 1, 100, true))
3454            .collect::<Vec<_>>();
3455        let windows = assign_to_windows(files.iter(), 1);
3456
3457        let output = priority_test_picker(1)
3458            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3459            .await
3460            .unwrap();
3461
3462        assert_eq!(1, output.len());
3463        assert_eq!(0, output_window(&output[0]));
3464    }
3465
3466    #[tokio::test]
3467    async fn test_multiple_slots_follow_class_priority_and_newer_first_within_class() {
3468        let files = [
3469            priority_test_files(0, 0, 40, false),
3470            priority_test_files(1, 0, 40, false),
3471            priority_test_files(2, 1, 40, false),
3472            priority_test_files(3, 1, 40, false),
3473            priority_test_files(4, 1, 100, true),
3474            priority_test_files(5, 1, 100, true),
3475        ]
3476        .into_iter()
3477        .flatten()
3478        .collect::<Vec<_>>();
3479        let windows = assign_to_windows(files.iter(), 1);
3480
3481        let output = priority_test_picker(5)
3482            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(5), None)
3483            .await
3484            .unwrap();
3485        let pop_priority = output
3486            .iter()
3487            .rev()
3488            .map(|output| (output.inputs[0].level(), output_window(output)))
3489            .collect::<Vec<_>>();
3490
3491        assert_eq!(vec![(0, 1), (0, 0), (1, 3), (1, 2), (1, 5)], pop_priority);
3492    }
3493
3494    #[tokio::test]
3495    async fn test_inactive_window_mixed_fallback_merges_within_rewrite_budget() {
3496        let files = [
3497            new_file_with_level_and_rows(0, 99, 1, 1, 1_000, 1_000_000),
3498            new_file_with_level_and_rows(0, 9, 0, 2, 10, 1),
3499        ];
3500        let windows = assign_to_windows(files.iter(), 100);
3501        let picker = TwcsPicker {
3502            trigger_file_num: 4,
3503            active_window_l1_merge_trigger: 8,
3504            inactive_window_trigger_file_num: 2,
3505            inactive_window_l1_merge_trigger: 8,
3506            time_window_seconds: Some(100),
3507            max_output_file_size: Some(2_000),
3508            append_mode: false,
3509            max_background_tasks: None,
3510            time_range: None,
3511        };
3512
3513        let output = picker
3514            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3515            .await
3516            .unwrap();
3517
3518        assert_eq!(1, output.len());
3519        assert_eq!(2, output[0].inputs.len());
3520    }
3521
3522    #[tokio::test]
3523    async fn test_inactive_window_over_budget_merges_l0_only() {
3524        // L0 files are unbalanced among themselves, and merging in the huge L1 file
3525        // would exceed the rewrite budget: only the L0 files are compacted.
3526        let files = [
3527            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10_000),
3528            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 10),
3529            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 3, 1_000_000),
3530        ];
3531        let windows = assign_to_windows(files.iter(), 100);
3532        let picker = TwcsPicker {
3533            trigger_file_num: 4,
3534            active_window_l1_merge_trigger: 8,
3535            inactive_window_trigger_file_num: 2,
3536            inactive_window_l1_merge_trigger: 8,
3537            time_window_seconds: Some(100),
3538            max_output_file_size: Some(100_000),
3539            append_mode: false,
3540            max_background_tasks: None,
3541            time_range: None,
3542        };
3543
3544        let output = picker
3545            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3546            .await
3547            .unwrap();
3548
3549        assert_eq!(1, output.len());
3550        assert_eq!(2, output[0].inputs.len());
3551        assert!(output[0].inputs.iter().all(|file| file.level() == 0));
3552    }
3553
3554    #[tokio::test]
3555    async fn test_inactive_window_over_budget_without_l0_pair_does_not_merge() {
3556        // A single tiny L0 and a huge L1: merging is over budget and there is no
3557        // L0 pair to converge, so the window is left as-is.
3558        let files = [
3559            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10),
3560            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 2, 1_000_000),
3561        ];
3562        let windows = assign_to_windows(files.iter(), 100);
3563        let picker = TwcsPicker {
3564            trigger_file_num: 4,
3565            active_window_l1_merge_trigger: 8,
3566            inactive_window_trigger_file_num: 2,
3567            inactive_window_l1_merge_trigger: 8,
3568            time_window_seconds: Some(100),
3569            max_output_file_size: Some(100_000),
3570            append_mode: false,
3571            max_background_tasks: None,
3572            time_range: None,
3573        };
3574
3575        let output = picker
3576            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3577            .await
3578            .unwrap();
3579
3580        assert!(output.is_empty());
3581    }
3582
3583    #[tokio::test]
3584    async fn test_inactive_window_falls_through_when_triggered_l0_pick_fails() {
3585        // L0 reaches the inactive trigger but is unbalanced; L1 is below the
3586        // trigger. The window must still converge through the budgeted mixed
3587        // fallback instead of being skipped.
3588        let files = [
3589            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 100_000),
3590            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 10),
3591            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 3, 50_000),
3592        ];
3593        let windows = assign_to_windows(files.iter(), 100);
3594        let picker = TwcsPicker {
3595            trigger_file_num: 4,
3596            active_window_l1_merge_trigger: 8,
3597            inactive_window_trigger_file_num: 2,
3598            inactive_window_l1_merge_trigger: 8,
3599            time_window_seconds: Some(100),
3600            max_output_file_size: Some(1_000_000),
3601            append_mode: false,
3602            max_background_tasks: None,
3603            time_range: None,
3604        };
3605
3606        let output = picker
3607            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3608            .await
3609            .unwrap();
3610
3611        assert_eq!(1, output.len());
3612        assert_eq!(3, output[0].inputs.len());
3613    }
3614
3615    #[test]
3616    fn test_mixed_candidate_row_balance() {
3617        for (case, l1_rows, l0_rows, expected_len) in [
3618            ("L1 rows dominate", 1_000_000, 10_000, 0),
3619            ("L1 rows meet ratio", 60_000, 10_000, 4),
3620            ("L0 rows are unknown", 1_000_000, 0, 4),
3621        ] {
3622            let files = vec![
3623                new_file_with_level_and_rows(0, 99, 1, 1, 100, l1_rows),
3624                new_file_with_level_and_rows(0, 9, 0, 2, 100, l0_rows),
3625                new_file_with_level_and_rows(20, 29, 0, 3, 100, l0_rows),
3626                new_file_with_level_and_rows(40, 49, 0, 4, 100, l0_rows),
3627            ];
3628
3629            let picked = pick_mixed_count_first(vec![SortedRun::from(files)], None);
3630
3631            assert_eq!(expected_len, picked.len(), "{case}");
3632        }
3633    }
3634
3635    #[test]
3636    fn test_count_first_prefers_smaller_bytes_when_file_counts_match() {
3637        let files = (0..=DEFAULT_MAX_INPUT_FILES)
3638            .map(|idx| {
3639                let start = idx as i64 * 20;
3640                let size = if idx == 0 {
3641                    100
3642                } else if idx == DEFAULT_MAX_INPUT_FILES {
3643                    1
3644                } else {
3645                    10
3646                };
3647                new_file(start, start + 9, idx as u64 + 1, size)
3648            })
3649            .collect::<Vec<_>>();
3650
3651        let picked = pick_count_first(vec![SortedRun::from(files)], None);
3652        let ranges = picked_ranges(&picked);
3653
3654        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3655        assert_eq!(Some(&(20, 29)), ranges.first());
3656        let last_start = DEFAULT_MAX_INPUT_FILES as i64 * 20;
3657        assert_eq!(Some(&(last_start, last_start + 9)), ranges.last());
3658    }
3659
3660    #[test]
3661    fn test_count_first_skips_pure_rewrite_without_progress() {
3662        // Four non-overlapping files whose combined size splits back into at least
3663        // four outputs: compacting them reduces neither the file count nor any
3664        // overlap, so there is nothing worth picking.
3665        let picked = pick_count_first(
3666            vec![SortedRun::from(vec![
3667                new_file(0, 9, 1, 600),
3668                new_file(20, 29, 2, 600),
3669                new_file(40, 49, 3, 600),
3670                new_file(60, 69, 4, 600),
3671            ])],
3672            Some(512),
3673        );
3674
3675        assert!(picked.is_empty());
3676    }
3677
3678    #[test]
3679    fn test_count_first_allows_overlap_resolution_without_file_reduction() {
3680        // Two large overlapping files from different runs: compacting them cannot
3681        // reduce the file count (the output splits back into just as many files),
3682        // but it resolves the overlap and merges two sorted runs into one.
3683        let picked = pick_count_first(
3684            vec![
3685                SortedRun::from(vec![new_file(0, 19, 1, 600)]),
3686                SortedRun::from(vec![new_file(10, 29, 2, 600)]),
3687            ],
3688            Some(512),
3689        );
3690
3691        assert_eq!(vec![(0, 19), (10, 29)], picked_ranges(&picked));
3692    }
3693
3694    #[test]
3695    fn test_count_first_prefers_guaranteed_reduction_over_pure_rewrite() {
3696        // A window mixing large and small files: the large triple on its own is a
3697        // pure rewrite, and every interval containing large files rewrites more
3698        // bytes for the same predicted reduction, so the small triple wins.
3699        let picked = pick_count_first(
3700            vec![SortedRun::from(vec![
3701                new_file(0, 9, 1, 600),
3702                new_file(10, 19, 2, 600),
3703                new_file(20, 29, 3, 600),
3704                new_file(30, 39, 4, 10),
3705                new_file(40, 49, 5, 10),
3706                new_file(50, 59, 6, 10),
3707            ])],
3708            Some(512),
3709        );
3710
3711        assert_eq!(vec![(30, 39), (40, 49), (50, 59)], picked_ranges(&picked));
3712    }
3713
3714    #[test]
3715    fn test_count_first_prefers_earlier_time_on_exact_tie() {
3716        let files = (0..=DEFAULT_MAX_INPUT_FILES)
3717            .map(|idx| {
3718                let start = idx as i64 * 20;
3719                new_file(start, start + 9, idx as u64 + 1, 10)
3720            })
3721            .collect::<Vec<_>>();
3722
3723        let picked = pick_count_first(vec![SortedRun::from(files)], None);
3724        let ranges = picked_ranges(&picked);
3725
3726        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3727        assert_eq!(Some(&(0, 9)), ranges.first());
3728        let last_start = (DEFAULT_MAX_INPUT_FILES as i64 - 1) * 20;
3729        assert_eq!(Some(&(last_start, last_start + 9)), ranges.last());
3730    }
3731
3732    #[test]
3733    fn test_count_first_keeps_each_interleaved_run_contiguous() {
3734        let picked = pick_count_first(
3735            vec![
3736                SortedRun::from(vec![
3737                    new_file(0, 9, 1, 1),
3738                    new_file(20, 29, 2, 1),
3739                    new_file(40, 49, 3, 1),
3740                ]),
3741                SortedRun::from(vec![new_file(10, 19, 4, 1), new_file(30, 39, 5, 1)]),
3742            ],
3743            None,
3744        );
3745
3746        assert_eq!(
3747            vec![(0, 9), (10, 19), (20, 29), (30, 39), (40, 49)],
3748            picked_ranges(&picked)
3749        );
3750    }
3751
3752    // TODO(hl): TTL tester that checks if get_expired_ssts function works as expected.
3753
3754    // Reproduces the leftover-small-files deadlock observed in the compaction bench:
3755    // an "EngineFull" flush emits several SSTs sharing one sequence. They must be ordinary
3756    // candidates so a contiguous candidate can span them instead of treating them as an
3757    // indivisible block that fragments the timeline.
3758    #[test]
3759    fn test_count_first_candidate_spans_same_sequence_files() {
3760        let picked = pick_count_first(
3761            vec![SortedRun::from(vec![
3762                new_file(0, 9, 1, 10),
3763                // One EngineFull flush output: three SSTs with the same sequence.
3764                new_file(10, 19, 2, 10),
3765                new_file(10, 19, 2, 10),
3766                new_file(10, 19, 2, 10),
3767                new_file(20, 29, 3, 10),
3768            ])],
3769            None,
3770        );
3771
3772        assert_eq!(
3773            vec![(0, 9), (10, 19), (10, 19), (10, 19), (20, 29)],
3774            picked_ranges(&picked)
3775        );
3776    }
3777
3778    // End-to-end repro of the compaction-bench leftover files: a window where
3779    // EngineFull flush groups (multiple SSTs sharing a sequence) sit between
3780    // singleton flushes. The picker must be able to merge the whole contiguous
3781    // range; otherwise these files can never be compacted once the active window
3782    // moves on.
3783    #[tokio::test]
3784    async fn test_count_first_merges_window_with_interleaved_flush_groups() {
3785        let files = [
3786            // Singleton flush.
3787            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10),
3788            // EngineFull flush: three SSTs sharing sequence 2.
3789            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
3790            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
3791            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
3792            // Singleton flush.
3793            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 3, 10),
3794        ];
3795        let windows = assign_to_windows(files.iter(), 100);
3796        let picker = TwcsPicker {
3797            trigger_file_num: 3,
3798            active_window_l1_merge_trigger: 8,
3799            inactive_window_trigger_file_num: 3,
3800            inactive_window_l1_merge_trigger: 8,
3801            time_window_seconds: Some(100),
3802            max_output_file_size: None,
3803            append_mode: false,
3804            max_background_tasks: None,
3805            time_range: None,
3806        };
3807
3808        let output = picker
3809            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3810            .await
3811            .unwrap();
3812
3813        assert_eq!(1, output.len());
3814        // All five SSTs of the window are merged in one compaction.
3815        assert_eq!(5, output[0].inputs.len());
3816    }
3817}