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
15mod units;
16
17use std::collections::hash_map::Entry;
18use std::collections::{BTreeMap, HashMap, HashSet};
19use std::fmt::Debug;
20use std::sync::{Arc, LazyLock};
21
22use common_base::readable_size::ReadableSize;
23use common_telemetry::{debug, info};
24use common_time::Timestamp;
25use common_time::range::TimestampRange;
26use common_time::timestamp::TimeUnit;
27use common_time::timestamp_millis::BucketAligned;
28use snafu::ResultExt;
29use store_api::storage::RegionId;
30
31use crate::compaction::CompactionOutput;
32use crate::compaction::buckets::infer_time_bucket;
33use crate::compaction::compactor::CompactionRegion;
34use crate::compaction::picker::{Picker, PickerOutput, get_expired_ssts};
35use crate::compaction::run::{
36    Ranged, SortedRun, files_overlap, files_overlap_inclusive, find_sorted_runs,
37    find_sorted_runs_by_time_range, merge_primary_key_ranges, primary_key_ranges_overlap,
38};
39use crate::compaction::twcs::units::{ClosedMergeOptions, PickLimits};
40use crate::error::{JoinSnafu, Result};
41use crate::region::options::MergeMode;
42use crate::sst::file::{FileHandle, Level, overlaps};
43use crate::sst::primary_key::PrimaryKeyRangeMapper;
44use crate::sst::version::LevelMeta;
45
46const LEVEL_COMPACTED: Level = 1;
47
48/// Scheduling priority of a window's pick. Earlier classes are scheduled first,
49/// and newer windows first within a class.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
51enum PickClass {
52    /// Merges L0 files, which also absorbs late data of inactive windows.
53    L0Merge,
54    /// Merges files to reduce the window's file count.
55    FileReduction,
56    /// Merges files of an inactive window to resolve their overlaps.
57    OverlapResolution,
58}
59
60/// Compaction inputs picked for one window.
61#[derive(Debug)]
62struct WindowPick {
63    class: PickClass,
64    /// Name of the strategy that picked the inputs, for logs.
65    branch: &'static str,
66    inputs: Vec<FileHandle>,
67    /// Number of sorted runs or units the strategy considered, for logs.
68    found_runs: usize,
69}
70
71struct WindowOutputContext {
72    active_window: Option<i64>,
73    time_window_size: Option<i64>,
74    max_outputs: Option<usize>,
75    primary_key_mapper: Arc<PrimaryKeyRangeMapper>,
76}
77
78/// A mixed L0/L1 compaction may rewrite at most this many L1 rows per L0 row.
79const MAX_L1_L0_ROW_RATIO: usize = 2;
80
81/// Default maximum number of input SST files in one compaction input.
82const DEFAULT_MAX_INPUT_FILES: usize = 16;
83
84const MAX_INPUT_FILES_ENV: &str = "GREPTIME_TWCS_MAX_INPUT_FILES";
85
86/// Maximum number of input SST files in one compaction input.
87/// Configurable via [`MAX_INPUT_FILES_ENV`].
88static MAX_INPUT_FILES: LazyLock<usize> = LazyLock::new(|| {
89    let env_value = std::env::var(MAX_INPUT_FILES_ENV).ok();
90    parse_max_input_files(env_value.as_deref())
91});
92
93/// A closed merge in an inactive window may take this many times
94/// [`MAX_INPUT_FILES`]. Resolving overlaps across a whole window needs all of
95/// its overlapping files at once, and each byte is rewritten once per window.
96const CLOSED_MERGE_INPUT_FACTOR: usize = 4;
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    /// LastNonNull picks expand to an overlap closure, so cannot use partial
125    /// small-file reductions that must leave large peers untouched.
126    pub(crate) merge_mode: MergeMode,
127    /// Max background compaction tasks.
128    pub max_background_tasks: Option<usize>,
129    /// Optional time range that constrains candidate compaction windows.
130    pub(crate) time_range: Option<TimestampRange>,
131    /// Picks the active window like an inactive one. Manual compactions set it,
132    /// so a window that stopped receiving writes still gets its overlaps
133    /// resolved.
134    pub(crate) all_windows_inactive: bool,
135}
136
137impl TwcsPicker {
138    /// Picks at most one compaction input per window and schedules them across
139    /// windows by [`PickClass`], newer windows first within a class.
140    async fn build_output(
141        &self,
142        region_id: RegionId,
143        time_windows: BTreeMap<i64, Window>,
144        context: WindowOutputContext,
145    ) -> Result<Vec<CompactionOutput>> {
146        let WindowOutputContext {
147            active_window,
148            time_window_size,
149            max_outputs,
150            primary_key_mapper,
151        } = context;
152        let region_sequence = time_windows
153            .values()
154            .flat_map(|window| window.files())
155            .filter_map(|file| file.meta_ref().sequence)
156            .map(|sequence| sequence.get())
157            .max()
158            .unwrap_or(0);
159        let windows = time_windows
160            .values()
161            .rev()
162            .filter(|window| {
163                !window.files.is_empty()
164                    && self.time_range.as_ref().is_none_or(|time_range| {
165                        time_window_size.is_none_or(|time_window_size| {
166                            time_window_intersects_range(
167                                window.time_window,
168                                time_window_size,
169                                time_range,
170                            )
171                        })
172                    })
173            })
174            .map(|window| window.time_window)
175            .collect::<Vec<_>>();
176        let time_windows = Arc::new(time_windows);
177        let max_outputs = max_outputs.unwrap_or(usize::MAX);
178        // Enumerating all LastNonNull seeds must not increase the number of
179        // concurrently planned windows along with the output limit.
180        let chunk_size = self.max_background_tasks.unwrap_or(windows.len()).max(1);
181        let mut picks = Vec::new();
182        for chunk in windows.chunks(chunk_size) {
183            let mut handles = Vec::with_capacity(chunk.len());
184            for &window in chunk {
185                let picker = self.clone();
186                let time_windows = time_windows.clone();
187                let primary_key_mapper = primary_key_mapper.clone();
188                handles.push(common_runtime::spawn_blocking_compact(move || {
189                    let pick = time_windows.get(&window).and_then(|files| {
190                        picker.pick_window(
191                            region_id,
192                            files,
193                            active_window,
194                            time_window_size,
195                            region_sequence,
196                            &primary_key_mapper,
197                        )
198                    });
199                    (window, pick)
200                }));
201                tokio::task::yield_now().await;
202            }
203            for result in futures::future::join_all(handles).await {
204                let (window, pick) = result.context(JoinSnafu)?;
205                picks.extend(pick.map(|pick| (window, pick)));
206            }
207            // Windows are visited newest first, so older windows cannot displace
208            // a full set of picks of the first class.
209            if picks
210                .iter()
211                .filter(|(_, pick)| pick.class == PickClass::L0Merge)
212                .count()
213                >= max_outputs
214            {
215                break;
216            }
217        }
218
219        // The stable sort keeps newer windows first within a class.
220        picks.sort_by_key(|(_, pick)| pick.class);
221        if picks.len() > max_outputs {
222            debug!(
223                "Region ({:?}) compaction output limit ({}) reached, remaining candidates discarded",
224                region_id, max_outputs
225            );
226            picks.truncate(max_outputs);
227        }
228        let mut output = picks
229            .into_iter()
230            .map(|(window, pick)| {
231                let files = &time_windows[&window];
232                let filter_deleted = !self.append_mode
233                    && !window_has_overlap(files, &time_windows)
234                    && !selected_overlaps_unselected(&pick.inputs, files, &primary_key_mapper);
235                if pick.inputs.len() > 1 {
236                    log_pick_result(
237                        region_id,
238                        window,
239                        active_window,
240                        &pick,
241                        files.files.len(),
242                        self.max_output_file_size,
243                        filter_deleted,
244                    );
245                }
246                CompactionOutput {
247                    output_level: LEVEL_COMPACTED, // always compact to l1
248                    inputs: pick.inputs,
249                    filter_deleted,
250                    output_time_range: None, // we do not enforce output time range in twcs compactions.
251                }
252            })
253            .collect::<Vec<_>>();
254        // The compactor pops outputs from the end.
255        output.reverse();
256        Ok(output)
257    }
258
259    /// Picks the compaction inputs of one window, or `None` if it needs none.
260    fn pick_window(
261        &self,
262        region_id: RegionId,
263        window: &Window,
264        active_window: Option<i64>,
265        time_window_size: Option<i64>,
266        region_sequence: u64,
267        mapper: &PrimaryKeyRangeMapper,
268    ) -> Option<WindowPick> {
269        let mut files: Vec<_> = window.files().cloned().collect();
270        let quiet = is_quiet_window(&files, region_sequence);
271
272        // Filter out large files in append mode - they won't benefit from compaction
273        if self.append_mode
274            && let Some(max_size) = self.max_output_file_size
275        {
276            let (kept_files, ignored_files) =
277                files.into_iter().partition(|file| file.size() <= max_size);
278            files = kept_files;
279            if !ignored_files.is_empty() {
280                info!(
281                    "Skipped {} large files in append mode for region {}, window {}, max_size: {}",
282                    ignored_files.len(),
283                    region_id,
284                    window.time_window,
285                    max_size
286                );
287            }
288        }
289
290        let (l0_files, l1_files): (Vec<_>, Vec<_>) =
291            files.into_iter().partition(|file| file.level() == 0);
292        if !self.all_windows_inactive && active_window == Some(window.time_window) {
293            self.pick_active_window(l0_files, l1_files, mapper)
294        } else if window.files.len() < self.inactive_window_trigger_file_num
295            && l1_files.len() < self.inactive_window_l1_merge_trigger
296        {
297            None
298        } else {
299            let settling = !self.all_windows_inactive
300                && is_settling_window(window.time_window, active_window, time_window_size);
301            let num_l1_files = l1_files.len();
302            self.pick_inactive_window(l0_files, l1_files, mapper, settling, quiet)
303                .or_else(|| {
304                    if self.merge_mode == MergeMode::LastNonNull
305                        || num_l1_files < self.inactive_window_l1_merge_trigger
306                    {
307                        return None;
308                    }
309                    // Include even append-mode files excluded from normal picks:
310                    // a small-file merge must not add conflicts with large peers.
311                    let pick = units::pick_small_l1(&window.files, mapper, self.pick_limits())?;
312                    Some(WindowPick {
313                        class: PickClass::FileReduction,
314                        branch: "inactive:pick_small_l1",
315                        inputs: pick.inputs,
316                        found_runs: pick.units,
317                    })
318                })
319        }
320    }
321
322    /// The active window keeps receiving writes, so every pick must reduce its
323    /// file count:
324    ///
325    /// 1. Merge L0 files once they reach the trigger.
326    /// 2. Otherwise, merge L1 files once they reach the L1 safety trigger. L1
327    ///    files are merged separately from L0 files to avoid chained rewrites.
328    ///
329    /// Overlaps are left for the window to resolve once it becomes inactive:
330    /// resolving them while data keeps arriving rewrites the window repeatedly,
331    /// and leaves a dominant run that blocks the inactive-window merge.
332    fn pick_active_window(
333        &self,
334        l0_files: Vec<FileHandle>,
335        l1_files: Vec<FileHandle>,
336        mapper: &PrimaryKeyRangeMapper,
337    ) -> Option<WindowPick> {
338        if l0_files.len() >= self.trigger_file_num {
339            let (inputs, found_runs) = pick_candidate_files(
340                l0_files,
341                self.max_output_file_size,
342                pick_count_first,
343                mapper,
344            );
345            if !inputs.is_empty() {
346                return Some(WindowPick {
347                    class: PickClass::L0Merge,
348                    branch: "active:pick_count_first",
349                    inputs,
350                    found_runs,
351                });
352            }
353        }
354        if l1_files.len() < self.active_window_l1_merge_trigger {
355            return None;
356        }
357        let pick = units::pick_active_l1(&l1_files, mapper, self.pick_limits())?;
358        Some(WindowPick {
359            class: PickClass::FileReduction,
360            branch: "active:pick_l1_file_reduction",
361            inputs: pick.inputs,
362            found_runs: pick.units,
363        })
364    }
365
366    /// An inactive window should converge at a bounded rewrite cost:
367    ///
368    /// 1. Absorb L0 files (late data), see [`pick_inactive_l0`].
369    /// 2. Otherwise, merge a closed group of files that resolves overlaps or
370    ///    removes files, see [`units::pick_closed_merge`]. Pure file reduction
371    ///    waits for the L1 trigger; overlap resolution does not.
372    ///
373    /// A `settling` window is the one right before the active window. Late data
374    /// still lands there, and any rewrite of its overlapping files would be
375    /// repeated once more late data arrives. It only merges L0 files among
376    /// themselves and conflict-free files, and leaves overlaps for when the
377    /// window is older.
378    ///
379    /// Late data can also land in older windows. Resolving such an overlap
380    /// rewrites a dominant sorted run for a small peer, which would repeat for
381    /// every late flush, so it waits until the window is `quiet`: the region
382    /// has written a window's worth of rows elsewhere since the window last
383    /// received data. Each quiet period then pays for at most one such rewrite.
384    /// A manual compaction is a one-off request to resolve overlaps, so it does
385    /// not wait.
386    ///
387    /// Without new writes each pick consumes an L0 file or makes progress on
388    /// the closed merge's termination measure, so the window stops compacting.
389    fn pick_inactive_window(
390        &self,
391        l0_files: Vec<FileHandle>,
392        l1_files: Vec<FileHandle>,
393        mapper: &PrimaryKeyRangeMapper,
394        settling: bool,
395        quiet: bool,
396    ) -> Option<WindowPick> {
397        let num_l1_files = l1_files.len();
398        if !l0_files.is_empty()
399            && let Some((inputs, found_runs, branch)) = pick_inactive_l0(
400                l0_files.clone(),
401                (!settling).then_some(l1_files.as_slice()),
402                self.max_output_file_size,
403                mapper,
404            )
405        {
406            return Some(WindowPick {
407                class: PickClass::L0Merge,
408                branch,
409                inputs,
410                found_runs,
411            });
412        }
413
414        let mut files = l1_files;
415        files.extend(l0_files);
416        let pick = units::pick_closed_merge(
417            &files,
418            mapper,
419            ClosedMergeOptions {
420                limits: self.closed_merge_limits(),
421                allow_file_reduction: num_l1_files >= self.inactive_window_l1_merge_trigger,
422                allow_conflicts: !settling,
423                allow_unbalanced: (quiet || self.all_windows_inactive) && !settling,
424            },
425        )?;
426        let (class, branch) = if pick.dense_fallback {
427            (PickClass::FileReduction, "inactive:dense_file_reduction")
428        } else if pick.reduces_files {
429            (PickClass::FileReduction, "inactive:pick_l1_file_reduction")
430        } else {
431            (PickClass::OverlapResolution, "inactive:pick_l1_overlap")
432        };
433        Some(WindowPick {
434            class,
435            branch,
436            inputs: pick.inputs,
437            found_runs: pick.units,
438        })
439    }
440
441    fn pick_limits(&self) -> PickLimits {
442        PickLimits {
443            max_output_file_size: self.max_output_file_size,
444            max_input_files: *MAX_INPUT_FILES,
445        }
446    }
447
448    fn closed_merge_limits(&self) -> PickLimits {
449        PickLimits {
450            max_input_files: MAX_INPUT_FILES.saturating_mul(CLOSED_MERGE_INPUT_FACTOR),
451            ..self.pick_limits()
452        }
453    }
454}
455
456/// Absorbs L0 files of an inactive window, in order of preference:
457///
458/// 1. Balanced merges of L0 files, which leave L1 files untouched.
459/// 2. Mixed merges that bypass the balance checks, but only when the total
460///    rewrite fits in the output file budget, bounding write amplification.
461///    Skipped without `l1_files`.
462/// 3. Merges of L0 files among themselves regardless of balance. The rewrite
463///    is bounded by the L0 bytes and leaves large compacted files untouched.
464///
465/// Every pick consumes L0 files.
466fn pick_inactive_l0(
467    l0_files: Vec<FileHandle>,
468    l1_files: Option<&[FileHandle]>,
469    max_output_file_size: Option<u64>,
470    mapper: &PrimaryKeyRangeMapper,
471) -> Option<(Vec<FileHandle>, usize, &'static str)> {
472    let (inputs, found_runs) = pick_candidate_files(
473        l0_files.clone(),
474        max_output_file_size,
475        pick_count_first,
476        mapper,
477    );
478    if !inputs.is_empty() {
479        return Some((inputs, found_runs, "inactive:pick_count_first"));
480    }
481    if let Some(l1_files) = l1_files {
482        let mut all_files = l0_files.clone();
483        all_files.extend_from_slice(l1_files);
484        let (inputs, found_runs) = pick_candidate_files(
485            all_files,
486            max_output_file_size,
487            pick_mixed_within_budget,
488            mapper,
489        );
490        if !inputs.is_empty() {
491            return Some((inputs, found_runs, "inactive:pick_mixed_within_budget"));
492        }
493    }
494    let (inputs, found_runs) = pick_candidate_files(
495        l0_files,
496        max_output_file_size,
497        pick_unbalanced_count_first,
498        mapper,
499    );
500    (!inputs.is_empty()).then_some((inputs, found_runs, "inactive:pick_unbalanced_count_first"))
501}
502
503type CandidatePicker =
504    fn(Vec<SortedRun<FileHandle>>, Option<u64>, &PrimaryKeyRangeMapper) -> Vec<FileHandle>;
505
506fn pick_candidate_files(
507    mut files: Vec<FileHandle>,
508    max_output_file_size: Option<u64>,
509    picker: CandidatePicker,
510    mapper: &PrimaryKeyRangeMapper,
511) -> (Vec<FileHandle>, usize) {
512    let sorted_runs = if files.len() < 1024 {
513        find_sorted_runs(&mut files, |lhs, rhs| files_overlap(lhs, rhs, mapper))
514    } else {
515        find_sorted_runs_by_time_range(&mut files)
516    };
517    let found_runs = sorted_runs.len();
518    (
519        picker(sorted_runs, max_output_file_size, mapper),
520        found_runs,
521    )
522}
523
524#[derive(Debug)]
525struct OrderedFile<'a> {
526    file: &'a FileHandle,
527    run_id: usize,
528    position_in_run: usize,
529    /// Primary key range in the pick's schema, resolved once per pick.
530    primary_key_range: Option<(bytes::Bytes, bytes::Bytes)>,
531}
532
533impl OrderedFile<'_> {
534    /// Same as [`files_overlap_inclusive`] with the cached key ranges.
535    fn overlaps_inclusive(&self, other: &Self) -> bool {
536        self.file.overlap_inclusive(other.file)
537            && match (&self.primary_key_range, &other.primary_key_range) {
538                (Some(lhs), Some(rhs)) => primary_key_ranges_overlap(lhs, rhs),
539                _ => true,
540            }
541    }
542}
543
544/// Metrics of a candidate compaction input, accumulated one file at a time.
545#[derive(Debug, Default)]
546struct Candidate {
547    /// Number of input files.
548    num_files: usize,
549    /// Total input bytes.
550    total_size: usize,
551    /// Size of the largest single input file.
552    largest_file_size: usize,
553    /// Files in the interval that overlap another interval file from a different
554    /// sorted run. Resolving these overlaps is what reduces sorted runs.
555    overlap_participants: usize,
556    /// Number of rows contributed by uncompacted files.
557    l0_rows: usize,
558    /// Number of rows contributed by compacted files.
559    l1_rows: usize,
560    has_l0: bool,
561    has_l1: bool,
562    /// Whether at least one file uses 0 to represent an unknown row count.
563    has_unknown_rows: bool,
564}
565
566impl Candidate {
567    /// Absorbs `file` into the candidate. `preceding` holds the interval files added
568    /// before it and `participations` tracks which of them already participate in an
569    /// intra-interval overlap; both exist only for overlap accounting.
570    fn absorb(
571        &mut self,
572        file: &OrderedFile,
573        preceding: &[OrderedFile],
574        participations: &mut Vec<bool>,
575    ) {
576        self.absorb_file(file.file);
577
578        let mut participates = false;
579        for (offset, other) in preceding.iter().enumerate() {
580            if file.run_id != other.run_id && file.overlaps_inclusive(other) {
581                if !participations[offset] {
582                    participations[offset] = true;
583                    self.overlap_participants += 1;
584                }
585                participates = true;
586            }
587        }
588        participations.push(participates);
589        if participates {
590            self.overlap_participants += 1;
591        }
592    }
593
594    /// Accumulates file statistics independently of overlap accounting.
595    fn absorb_file(&mut self, file: &FileHandle) {
596        self.num_files += 1;
597        let file_size = file.size() as usize;
598        self.total_size += file_size;
599        self.largest_file_size = self.largest_file_size.max(file_size);
600        self.absorb_level_rows(file);
601    }
602
603    fn absorb_level_rows(&mut self, file: &FileHandle) {
604        let num_rows = file.num_rows();
605        self.has_unknown_rows |= num_rows == 0;
606        if file.level() == 0 {
607            self.has_l0 = true;
608            self.l0_rows = self.l0_rows.saturating_add(num_rows);
609        } else {
610            self.has_l1 = true;
611            self.l1_rows = self.l1_rows.saturating_add(num_rows);
612        }
613    }
614
615    /// Predicted number of output files after compacting this candidate. Compaction
616    /// output holds at most the input bytes, so a split threshold of
617    /// `max_output_file_size` yields at most `ceil(total_size / threshold)` files.
618    /// Without a threshold the output is a single file.
619    fn predicted_output_files(&self, max_output_file_size: Option<u64>) -> usize {
620        match max_output_file_size {
621            Some(max) if max > 0 => self.total_size.div_ceil(max as usize).max(1),
622            _ => 1,
623        }
624    }
625
626    /// Net file count reduction compacting this candidate would achieve, i.e. how
627    /// many fewer physical SSTs the window holds afterwards. Zero when the output
628    /// would be split back into at least as many files as the input.
629    fn file_reduction(&self, max_output_file_size: Option<u64>) -> usize {
630        self.num_files
631            .saturating_sub(self.predicted_output_files(max_output_file_size))
632    }
633
634    /// A large historical file is only rewritten once its peers contribute at least
635    /// the same amount of data, so every rewrite moves it into a larger size tier.
636    fn is_balanced(&self) -> bool {
637        self.largest_file_size <= self.total_size - self.largest_file_size
638    }
639
640    /// Bounds rewrite amplification for mixed L0/L1 candidates. Legacy files with
641    /// unknown row counts retain the byte-balance behavior above.
642    fn has_balanced_level_rows(&self) -> bool {
643        !self.has_l0
644            || !self.has_l1
645            || self.has_unknown_rows
646            || self.l1_rows <= self.l0_rows.saturating_mul(MAX_L1_L0_ROW_RATIO)
647    }
648
649    fn has_mixed_levels(&self) -> bool {
650        self.has_l0 && self.has_l1
651    }
652
653    /// Whether rewriting this candidate stays within the output file budget.
654    /// Inactive-window merges use this to bound write amplification when they
655    /// bypass the balance checks. An unset budget means no bound: the operator
656    /// removed the output size limit explicitly.
657    fn within_rewrite_budget(&self, max_output_file_size: Option<u64>) -> bool {
658        match max_output_file_size {
659            Some(limit) if limit > 0 => self.total_size <= limit as usize,
660            _ => true,
661        }
662    }
663
664    /// A candidate is worth compacting only if it makes progress on at least one
665    /// axis: it reduces the physical file count, or it resolves at least one
666    /// overlap (merging sorted runs and reducing read amplification). A pure
667    /// rewrite that achieves neither only burns I/O.
668    fn makes_progress(&self, max_output_file_size: Option<u64>) -> bool {
669        self.file_reduction(max_output_file_size) > 0 || self.overlap_participants > 0
670    }
671
672    fn score(&self, max_output_file_size: Option<u64>) -> CandidateScore {
673        CandidateScore {
674            file_reduction: self.file_reduction(max_output_file_size),
675            overlap_participants: self.overlap_participants,
676            total_size: self.total_size,
677        }
678    }
679}
680
681/// Score of a candidate compaction input. Higher is better: first by the predicted
682/// net file count reduction, then by the number of files participating in an
683/// overlap, then by smaller input bytes.
684#[derive(Debug)]
685struct CandidateScore {
686    file_reduction: usize,
687    overlap_participants: usize,
688    total_size: usize,
689}
690
691impl CandidateScore {
692    fn is_better_than(&self, other: &Self) -> bool {
693        self.file_reduction
694            .cmp(&other.file_reduction)
695            .then_with(|| self.overlap_participants.cmp(&other.overlap_participants))
696            .then_with(|| other.total_size.cmp(&self.total_size))
697            .is_gt()
698    }
699}
700
701/// Picks a contiguous (in global time order) interval of files to compact.
702///
703/// The picker reorders all files from all sorted runs by `(start asc, end desc)` and
704/// enumerates every interval of at most [`MAX_INPUT_FILES`] files as a [`Candidate`].
705/// An interval is eligible when it
706///
707/// - holds at least 2 files,
708/// - passes the caller-supplied eligibility predicate (e.g. the byte- and
709///   row-balance checks for regular picks, the rewrite budget for inactive
710///   window fallbacks),
711/// - makes progress on at least one axis: it reduces the physical file count given
712///   the output split threshold `max_output_file_size`, or it resolves at least one
713///   overlap between sorted runs.
714///
715/// The best interval wins by [`CandidateScore`]; ties keep the earliest interval.
716fn pick_count_first(
717    sorted_runs: Vec<SortedRun<FileHandle>>,
718    max_output_file_size: Option<u64>,
719    mapper: &PrimaryKeyRangeMapper,
720) -> Vec<FileHandle> {
721    pick_count_first_where(
722        sorted_runs,
723        max_output_file_size,
724        mapper,
725        is_balanced_candidate,
726    )
727}
728
729#[cfg(test)]
730fn pick_mixed_count_first(
731    sorted_runs: Vec<SortedRun<FileHandle>>,
732    max_output_file_size: Option<u64>,
733    mapper: &PrimaryKeyRangeMapper,
734) -> Vec<FileHandle> {
735    pick_count_first_where(sorted_runs, max_output_file_size, mapper, |candidate| {
736        candidate.has_mixed_levels() && is_balanced_candidate(candidate)
737    })
738}
739
740/// Mixed-level fallback for inactive windows: bypasses the balance checks but
741/// bounds the total rewrite to the output file budget.
742fn pick_mixed_within_budget(
743    sorted_runs: Vec<SortedRun<FileHandle>>,
744    max_output_file_size: Option<u64>,
745    mapper: &PrimaryKeyRangeMapper,
746) -> Vec<FileHandle> {
747    pick_count_first_where(sorted_runs, max_output_file_size, mapper, |candidate| {
748        candidate.has_mixed_levels() && candidate.within_rewrite_budget(max_output_file_size)
749    })
750}
751
752/// Last-resort pick for inactive windows with no balance requirement at all.
753/// Callers must only pass single-level (L0) files so the rewrite stays bounded
754/// by their bytes.
755fn pick_unbalanced_count_first(
756    sorted_runs: Vec<SortedRun<FileHandle>>,
757    max_output_file_size: Option<u64>,
758    mapper: &PrimaryKeyRangeMapper,
759) -> Vec<FileHandle> {
760    pick_count_first_where(sorted_runs, max_output_file_size, mapper, |_| true)
761}
762
763fn is_balanced_candidate(candidate: &Candidate) -> bool {
764    candidate.is_balanced() && candidate.has_balanced_level_rows()
765}
766
767fn pick_count_first_where(
768    sorted_runs: Vec<SortedRun<FileHandle>>,
769    max_output_file_size: Option<u64>,
770    mapper: &PrimaryKeyRangeMapper,
771    is_eligible: impl Fn(&Candidate) -> bool,
772) -> Vec<FileHandle> {
773    let files = ordered_files(&sorted_runs, mapper);
774
775    let mut best = None;
776    for left in 0..files.len() {
777        let mut candidate = Candidate::default();
778        let right_bound = left.saturating_add(*MAX_INPUT_FILES).min(files.len());
779        let mut participations: Vec<bool> = Vec::with_capacity(right_bound - left);
780        for right in left..right_bound {
781            candidate.absorb(&files[right], &files[left..right], &mut participations);
782            if candidate.num_files < 2
783                || !is_eligible(&candidate)
784                || !candidate.makes_progress(max_output_file_size)
785            {
786                continue;
787            }
788
789            let score = candidate.score(max_output_file_size);
790            if best
791                .as_ref()
792                .is_none_or(|(best_score, _)| score.is_better_than(best_score))
793            {
794                best = Some((score, &files[left..=right]));
795            }
796        }
797    }
798
799    let Some((_, best)) = best else {
800        return vec![];
801    };
802    best.iter().map(|file| file.file.clone()).collect()
803}
804
805/// Flattens sorted runs into files ordered by `(start asc, end desc)`, breaking ties
806/// by run and position to keep the order deterministic.
807fn ordered_files<'a>(
808    sorted_runs: &'a [SortedRun<FileHandle>],
809    mapper: &PrimaryKeyRangeMapper,
810) -> Vec<OrderedFile<'a>> {
811    let mut files = sorted_runs
812        .iter()
813        .enumerate()
814        .flat_map(|(run_id, run)| {
815            run.items()
816                .iter()
817                .enumerate()
818                .map(move |(position_in_run, file)| OrderedFile {
819                    file,
820                    run_id,
821                    position_in_run,
822                    primary_key_range: file.primary_key_range(mapper),
823                })
824        })
825        .collect::<Vec<_>>();
826    files.sort_unstable_by(|lhs, rhs| {
827        let (lhs_start, lhs_end) = lhs.file.range();
828        let (rhs_start, rhs_end) = rhs.file.range();
829        lhs_start
830            .cmp(&rhs_start)
831            .then_with(|| rhs_end.cmp(&lhs_end))
832            .then_with(|| lhs.run_id.cmp(&rhs.run_id))
833            .then_with(|| lhs.position_in_run.cmp(&rhs.position_in_run))
834    });
835    files
836}
837
838fn selected_overlaps_unselected(
839    selected: &[FileHandle],
840    window: &Window,
841    mapper: &PrimaryKeyRangeMapper,
842) -> bool {
843    // The overall time span of the selection: a file outside it cannot overlap any
844    // selected file (ranges are inclusive), so it needs no precise overlap check.
845    let Some((span_start, span_end)) = selected
846        .iter()
847        .map(Ranged::range)
848        .reduce(|(start_a, end_a), (start_b, end_b)| (start_a.min(start_b), end_a.max(end_b)))
849    else {
850        return false;
851    };
852    let selected_file_ids = selected
853        .iter()
854        .map(FileHandle::file_id)
855        .collect::<HashSet<_>>();
856    window
857        .files()
858        .filter(|file| {
859            let (start, end) = file.range();
860            start <= span_end && span_start <= end
861        })
862        .filter(|file| !selected_file_ids.contains(&file.file_id()))
863        .any(|unselected| {
864            selected
865                .iter()
866                .any(|selected| files_overlap_inclusive(selected, unselected, mapper))
867        })
868}
869
870fn log_pick_result(
871    region_id: RegionId,
872    window: i64,
873    active_window: Option<i64>,
874    pick: &WindowPick,
875    file_num: usize,
876    max_output_file_size: Option<u64>,
877    filter_deleted: bool,
878) {
879    let input_file_str: Vec<String> = pick
880        .inputs
881        .iter()
882        .map(|f| {
883            let range = f.range();
884            let start = range.0.to_iso8601_string();
885            let end = range.1.to_iso8601_string();
886            let num_rows = f.num_rows();
887            format!(
888                "File{{id: {:?}, range: ({}, {}), size: {}, num rows: {} }}",
889                f.file_id(),
890                start,
891                end,
892                ReadableSize(f.size()),
893                num_rows
894            )
895        })
896        .collect();
897    let window_str = Timestamp::new_second(window).to_iso8601_string();
898    let active_window_str = active_window.map(|s| Timestamp::new_second(s).to_iso8601_string());
899    let max_output_file_size = max_output_file_size.map(|size| ReadableSize(size).to_string());
900    info!(
901        "Region ({:?}) compaction pick result: current window: {}, active window: {:?}, branch: {}, \
902            found runs: {}, file num: {}, max output file size: {:?}, filter deleted: {}, \
903            input files: {:?}",
904        region_id,
905        window_str,
906        active_window_str,
907        pick.branch,
908        pick.found_runs,
909        file_num,
910        max_output_file_size,
911        filter_deleted,
912        input_file_str
913    );
914}
915
916#[async_trait::async_trait]
917impl Picker for TwcsPicker {
918    async fn pick(&self, compaction_region: &CompactionRegion) -> Result<Option<PickerOutput>> {
919        self.pick_with_output_limit(compaction_region, self.max_background_tasks)
920            .await
921    }
922}
923
924impl TwcsPicker {
925    pub(crate) async fn pick_with_output_limit(
926        &self,
927        compaction_region: &CompactionRegion,
928        max_outputs: Option<usize>,
929    ) -> Result<Option<PickerOutput>> {
930        let region_id = compaction_region.region_id;
931        let picker = self.clone();
932        let compaction_region = compaction_region.clone();
933        let primary_key_mapper = compaction_region.current_version.ssts.primary_key_mapper();
934        let window_mapper = primary_key_mapper.clone();
935        let (expired_ssts, time_window_size, active_window, windows) =
936            common_runtime::spawn_blocking_compact(move || {
937                let levels = compaction_region.current_version.ssts.levels();
938                let expired_ssts = get_expired_ssts(
939                    levels,
940                    compaction_region.ttl,
941                    Timestamp::current_millis(),
942                );
943                if !expired_ssts.is_empty() {
944                    info!("Expired SSTs in region {}: {:?}", region_id, expired_ssts);
945                }
946                let expired_file_ids = expired_ssts
947                    .iter()
948                    .map(|file| file.file_id())
949                    .collect::<HashSet<_>>();
950
951                let compaction_time_window = compaction_region
952                    .current_version
953                    .compaction_time_window
954                    .map(|window| window.as_secs() as i64);
955                let time_window_size = compaction_time_window
956                    .or(picker.time_window_seconds)
957                    .unwrap_or_else(|| {
958                        let inferred = infer_time_bucket(levels[0].files());
959                        info!(
960                            "Compaction window for region {} is not present, inferring from files: {:?}",
961                            region_id, inferred
962                        );
963                        inferred
964                    });
965
966                let windows = assign_to_windows(
967                    levels
968                        .iter()
969                        .flat_map(LevelMeta::files)
970                        .filter(|file| !expired_file_ids.contains(&file.file_id())),
971                    time_window_size,
972                    &window_mapper,
973                );
974                // Compute activity from the candidate files so expired or
975                // compacting files cannot identify a window absent from `windows`.
976                let active_window = find_active_window_by_sequence(
977                    windows.values().flat_map(Window::files),
978                    time_window_size,
979                )
980                .or_else(|| {
981                    find_latest_window_in_seconds(
982                        windows
983                            .values()
984                            .flat_map(Window::files)
985                            .filter(|file| file.level() == 0),
986                        time_window_size,
987                    )
988                });
989
990                (expired_ssts, time_window_size, active_window, windows)
991            })
992            .await
993            .context(JoinSnafu)?;
994
995        let outputs = self
996            .build_output(
997                region_id,
998                windows,
999                WindowOutputContext {
1000                    active_window,
1001                    time_window_size: Some(time_window_size),
1002                    max_outputs,
1003                    primary_key_mapper,
1004                },
1005            )
1006            .await?;
1007
1008        if outputs.is_empty() && expired_ssts.is_empty() {
1009            return Ok(None);
1010        }
1011
1012        // The picker treats zero as unlimited, but the SST writer would split every batch.
1013        let max_file_size = self
1014            .max_output_file_size
1015            .filter(|size| *size > 0)
1016            .map(|size| size as usize);
1017        Ok(Some(PickerOutput {
1018            outputs,
1019            expired_ssts,
1020            time_window_size,
1021            max_file_size,
1022        }))
1023    }
1024}
1025
1026#[derive(Clone)]
1027struct Window {
1028    start: Timestamp,
1029    end: Timestamp,
1030    files: Vec<FileHandle>,
1031    /// Start of the half-open window, in seconds.
1032    time_window: i64,
1033    primary_key_range: Option<(bytes::Bytes, bytes::Bytes)>,
1034}
1035
1036impl Window {
1037    /// Creates a new [Window] with given file.
1038    fn new_with_file(file: FileHandle, mapper: &PrimaryKeyRangeMapper) -> Self {
1039        let (start, end) = file.time_range();
1040        let primary_key_range = file.primary_key_range(mapper);
1041        Self {
1042            start,
1043            end,
1044            files: vec![file],
1045            time_window: 0,
1046            primary_key_range,
1047        }
1048    }
1049
1050    /// Returns the time range of all files in current window (inclusive).
1051    fn range(&self) -> (Timestamp, Timestamp) {
1052        (self.start, self.end)
1053    }
1054
1055    /// Adds a new file to window and updates time range.
1056    fn add_file(&mut self, file: FileHandle, mapper: &PrimaryKeyRangeMapper) {
1057        let (start, end) = file.time_range();
1058        self.start = self.start.min(start);
1059        self.end = self.end.max(end);
1060        self.primary_key_range = merge_primary_key_ranges(
1061            self.primary_key_range.take(),
1062            file.primary_key_range(mapper),
1063        );
1064        self.files.push(file);
1065    }
1066
1067    fn files(&self) -> impl Iterator<Item = &FileHandle> {
1068        self.files.iter()
1069    }
1070}
1071
1072/// Whether `window` is the one right before `active_window`, where late data
1073/// still lands.
1074fn is_settling_window(
1075    window: i64,
1076    active_window: Option<i64>,
1077    time_window_size: Option<i64>,
1078) -> bool {
1079    active_window
1080        .zip(time_window_size)
1081        .is_some_and(|(active, size)| {
1082            // Windows at the timestamp limits may be too far apart to subtract.
1083            active
1084                .checked_sub(window)
1085                .is_some_and(|distance| (1..=size).contains(&distance))
1086        })
1087}
1088
1089/// Whether the region has written at least as many rows as `files` hold, and at
1090/// least one row, since the newest of them was written. Sequences are assigned
1091/// per row and compaction keeps the inputs' maximum, so this needs no state.
1092/// Files written before row counts were recorded have zero rows, so a window
1093/// holding them is not quiet.
1094fn is_quiet_window(files: &[FileHandle], region_sequence: u64) -> bool {
1095    if files.iter().any(|file| file.num_rows() == 0) {
1096        return false;
1097    }
1098    let Some(window_sequence) = files
1099        .iter()
1100        .filter_map(|file| file.meta_ref().sequence)
1101        .map(|sequence| sequence.get())
1102        .max()
1103    else {
1104        return false;
1105    };
1106    let rows: u64 = files.iter().map(|file| file.num_rows() as u64).sum();
1107    region_sequence.saturating_sub(window_sequence) >= rows.max(1)
1108}
1109
1110/// Assigns files to windows with predefined window size (in seconds) by their max timestamps.
1111fn assign_to_windows<'a>(
1112    files: impl Iterator<Item = &'a FileHandle>,
1113    time_window_size: i64,
1114    mapper: &PrimaryKeyRangeMapper,
1115) -> BTreeMap<i64, Window> {
1116    let mut windows: HashMap<i64, Window> = HashMap::new();
1117    // Iterates all files and assign to time windows according to max timestamp
1118    for f in files {
1119        if f.compacting() {
1120            continue;
1121        }
1122        let (_, end) = f.time_range();
1123        let time_window = time_window_start(end, time_window_size);
1124
1125        match windows.entry(time_window) {
1126            Entry::Occupied(mut e) => {
1127                e.get_mut().add_file(f.clone(), mapper);
1128            }
1129            Entry::Vacant(e) => {
1130                let mut window = Window::new_with_file(f.clone(), mapper);
1131                window.time_window = time_window;
1132                e.insert(window);
1133            }
1134        }
1135    }
1136    windows.into_iter().collect()
1137}
1138
1139/// Uses the same half-open windows as memtables, including timestamps in the first
1140/// second after a boundary. An unrepresentable window start is clamped to `i64::MIN`.
1141fn time_window_start(timestamp: Timestamp, time_window_size: i64) -> i64 {
1142    // Conversion to seconds cannot overflow.
1143    timestamp
1144        .convert_to(TimeUnit::Second)
1145        .and_then(|ts| ts.value().align_by_bucket(time_window_size))
1146        .unwrap_or(i64::MIN)
1147}
1148
1149fn time_window_intersects_range(
1150    window_start: i64,
1151    time_window_size: i64,
1152    time_range: &TimestampRange,
1153) -> bool {
1154    if time_range.is_empty() {
1155        return false;
1156    }
1157    let first_window = time_range
1158        .start()
1159        .map_or(i64::MIN, |start| time_window_start(start, time_window_size));
1160    let last_window = match time_range.end() {
1161        None => i64::MAX,
1162        Some(end) => {
1163            // The range end is exclusive; keep the last second that intersects it.
1164            let Some(last_second) = end
1165                .convert_to_ceil(TimeUnit::Second)
1166                .and_then(|timestamp| timestamp.value().checked_sub(1))
1167            else {
1168                return false;
1169            };
1170            time_window_start(Timestamp::new_second(last_second), time_window_size)
1171        }
1172    };
1173    (first_window..=last_window).contains(&window_start)
1174}
1175
1176fn window_has_overlap(this: &Window, windows: &BTreeMap<i64, Window>) -> bool {
1177    windows
1178        .values()
1179        .filter(|that| this.time_window != that.time_window)
1180        .any(|that| {
1181            overlaps(&this.range(), &that.range()) && {
1182                match (&this.primary_key_range, &that.primary_key_range) {
1183                    (Some(l), Some(r)) => primary_key_ranges_overlap(l, r),
1184                    _ => true,
1185                }
1186            }
1187        })
1188}
1189
1190/// Finds the latest active writing window among all files.
1191/// Returns `None` when there are no files.
1192fn find_latest_window_in_seconds<'a>(
1193    files: impl Iterator<Item = &'a FileHandle>,
1194    time_window_size: i64,
1195) -> Option<i64> {
1196    let mut latest_timestamp = None;
1197    for f in files {
1198        let (_, end) = f.time_range();
1199        if let Some(latest) = latest_timestamp {
1200            if end > latest {
1201                latest_timestamp = Some(end);
1202            }
1203        } else {
1204            latest_timestamp = Some(end);
1205        }
1206    }
1207    latest_timestamp.map(|ts| time_window_start(ts, time_window_size))
1208}
1209
1210/// Finds the active window from the file with the highest sequence number.
1211///
1212/// Flush and compaction outputs inherit the max input sequence, so the
1213/// max-sequence file always tracks the most recent write: the active window
1214/// survives the transient state where level 0 is empty right after its files
1215/// were compacted away. Returns `None` when no file carries a sequence.
1216///
1217/// The window key is its inclusive start, as in [`assign_to_windows`].
1218fn find_active_window_by_sequence<'a>(
1219    files: impl Iterator<Item = &'a FileHandle>,
1220    time_window_size: i64,
1221) -> Option<i64> {
1222    files
1223        .filter(|f| f.meta_ref().sequence.is_some())
1224        .max_by_key(|f| f.meta_ref().sequence)
1225        .map(|f| time_window_start(f.time_range().1, time_window_size))
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use std::collections::HashSet;
1231    use std::num::NonZeroU64;
1232    use std::sync::Arc;
1233    use std::time::Duration;
1234
1235    use common_base::Plugins;
1236    use common_time::range::TimestampRange;
1237    use store_api::storage::FileId;
1238
1239    use super::*;
1240    use crate::cache::CacheManager;
1241    use crate::compaction::compactor::CompactionVersion;
1242    use crate::compaction::test_util::{
1243        compaction_region_with_ssts, new_file_handle, new_file_handle_with_sequence,
1244        new_file_handle_with_size_and_sequence,
1245        new_file_handle_with_size_sequence_and_primary_key_range, pk_range,
1246        primary_key_mapper_for_test,
1247    };
1248    use crate::config::MitoConfig;
1249    use crate::region::options::RegionOptions;
1250    use crate::sst::file::{FileMeta, Level};
1251    use crate::sst::version::SstVersion;
1252    use crate::test_util::memtable_util::metadata_for_test;
1253    use crate::test_util::scheduler_util::SchedulerEnv;
1254
1255    fn assign_to_windows<'a>(
1256        files: impl Iterator<Item = &'a FileHandle>,
1257        time_window_size: i64,
1258    ) -> BTreeMap<i64, Window> {
1259        super::assign_to_windows(files, time_window_size, &primary_key_mapper_for_test())
1260    }
1261
1262    fn pick_count_first(
1263        sorted_runs: Vec<SortedRun<FileHandle>>,
1264        max_output_file_size: Option<u64>,
1265    ) -> Vec<FileHandle> {
1266        super::pick_count_first(
1267            sorted_runs,
1268            max_output_file_size,
1269            &primary_key_mapper_for_test(),
1270        )
1271    }
1272
1273    fn pick_mixed_count_first(
1274        sorted_runs: Vec<SortedRun<FileHandle>>,
1275        max_output_file_size: Option<u64>,
1276    ) -> Vec<FileHandle> {
1277        super::pick_mixed_count_first(
1278            sorted_runs,
1279            max_output_file_size,
1280            &primary_key_mapper_for_test(),
1281        )
1282    }
1283
1284    impl TwcsPicker {
1285        async fn build_output_with_time_range(
1286            &self,
1287            region_id: RegionId,
1288            time_windows: BTreeMap<i64, Window>,
1289            active_window: Option<i64>,
1290            time_window_size: Option<i64>,
1291        ) -> Result<Vec<CompactionOutput>> {
1292            self.build_output(
1293                region_id,
1294                time_windows,
1295                WindowOutputContext {
1296                    active_window,
1297                    time_window_size,
1298                    max_outputs: self.max_background_tasks,
1299                    primary_key_mapper: Arc::new(primary_key_mapper_for_test()),
1300                },
1301            )
1302            .await
1303        }
1304    }
1305
1306    #[test]
1307    fn test_cross_schema_pk_overlap_before_window_aggregation() {
1308        use crate::test_util::sst_util::{new_primary_key, sst_region_metadata};
1309
1310        let metadata = Arc::new(sst_region_metadata());
1311        let mut old = FileMeta {
1312            region_id: metadata.region_id,
1313            file_id: FileId::random(),
1314            time_range: (
1315                Timestamp::new_millisecond(0),
1316                Timestamp::new_millisecond(1000),
1317            ),
1318            primary_key_min: Some(new_primary_key(&["a"]).into()),
1319            primary_key_max: Some(new_primary_key(&["b"]).into()),
1320            ..Default::default()
1321        };
1322        let mut completed = new_primary_key(&["b"]);
1323        completed.push(0); // The appended nullable tag's default.
1324        let new = FileMeta {
1325            file_id: FileId::random(),
1326            time_range: (
1327                Timestamp::new_millisecond(500),
1328                Timestamp::new_millisecond(2000),
1329            ),
1330            primary_key_min: Some(completed.clone().into()),
1331            primary_key_max: Some(completed.clone().into()),
1332            ..old.clone()
1333        };
1334        assert!(old.primary_key_max < new.primary_key_min);
1335        let mut ssts = SstVersion::new(metadata);
1336        let old_id = old.file_id;
1337        let new_id = new.file_id;
1338        ssts.add_files(
1339            crate::test_util::new_noop_file_purger(),
1340            [old.clone(), new].into_iter(),
1341        );
1342        let old_file = &ssts.levels()[0].files[&old_id];
1343        let new_file = &ssts.levels()[0].files[&new_id];
1344        let ranges = ssts.primary_key_mapper();
1345        let windows = super::assign_to_windows([old_file, new_file].into_iter(), 1, &ranges);
1346        assert_eq!(2, windows.len());
1347        assert!(
1348            windows
1349                .values()
1350                .all(|window| window_has_overlap(window, &windows))
1351        );
1352
1353        let mut window = Window::new_with_file(old_file.clone(), &ranges);
1354        window.add_file(new_file.clone(), &ranges);
1355        assert!(selected_overlaps_unselected(
1356            std::slice::from_ref(new_file),
1357            &window,
1358            &ranges,
1359        ));
1360        assert_eq!(
1361            Some(completed.into()),
1362            window
1363                .primary_key_range
1364                .as_ref()
1365                .map(|range| range.1.clone())
1366        );
1367
1368        // In the same target schema, a genuinely disjoint range still prunes.
1369        old.file_id = FileId::random();
1370        old.primary_key_max = old.primary_key_min.clone();
1371        let disjoint_id = old.file_id;
1372        ssts.add_files(crate::test_util::new_noop_file_purger(), [old].into_iter());
1373        let files = &ssts.levels()[0].files;
1374        assert!(!files_overlap_inclusive(
1375            &files[&disjoint_id],
1376            &files[&new_id],
1377            &ranges
1378        ));
1379    }
1380
1381    #[test]
1382    fn test_valid_max_input_files_env_overrides_default() {
1383        assert_eq!(64, parse_max_input_files(Some("64")));
1384    }
1385
1386    #[test]
1387    fn test_invalid_max_input_files_env_falls_back_to_default() {
1388        for env_value in [None, Some(""), Some("invalid"), Some("0"), Some("1")] {
1389            assert_eq!(16, parse_max_input_files(env_value));
1390        }
1391    }
1392
1393    async fn compaction_region_with_expired_sst() -> CompactionRegion {
1394        compaction_region_with_ssts(
1395            (1..=4).map(|sequence| FileMeta {
1396                file_id: FileId::random(),
1397                time_range: (
1398                    Timestamp::new_millisecond(0),
1399                    Timestamp::new_millisecond(10),
1400                ),
1401                level: 0,
1402                sequence: NonZeroU64::new(sequence),
1403                ..Default::default()
1404            }),
1405            Duration::from_millis(1),
1406        )
1407        .await
1408    }
1409
1410    #[tokio::test]
1411    async fn test_pick_normalizes_zero_output_size_to_unlimited() {
1412        let mut compaction_region = compaction_region_with_ssts(
1413            (1..=4).map(|sequence| new_file(0, 10, sequence, 100).meta_ref().clone()),
1414            Duration::from_secs(3600),
1415        )
1416        .await;
1417        compaction_region.ttl = None;
1418
1419        for (max_output_file_size, expected_max_file_size) in
1420            [(Some(0), None), (None, None), (Some(1024), Some(1024))]
1421        {
1422            let picker = TwcsPicker {
1423                trigger_file_num: 4,
1424                active_window_l1_merge_trigger: 8,
1425                inactive_window_trigger_file_num: 4,
1426                inactive_window_l1_merge_trigger: 8,
1427                time_window_seconds: Some(3),
1428                max_output_file_size,
1429                append_mode: false,
1430                merge_mode: MergeMode::LastRow,
1431                max_background_tasks: None,
1432                time_range: None,
1433                all_windows_inactive: false,
1434            };
1435
1436            let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1437
1438            assert_eq!(output.outputs.len(), 1);
1439            assert_eq!(output.outputs[0].inputs.len(), 4);
1440            assert!(output.expired_ssts.is_empty());
1441            assert_eq!(output.max_file_size, expected_max_file_size);
1442        }
1443    }
1444
1445    #[tokio::test]
1446    async fn test_pick_expired_ssts_without_marking_compacting() {
1447        let picker = TwcsPicker {
1448            trigger_file_num: 4,
1449            active_window_l1_merge_trigger: 8,
1450            inactive_window_trigger_file_num: 4,
1451            inactive_window_l1_merge_trigger: 8,
1452            time_window_seconds: Some(3),
1453            max_output_file_size: None,
1454            append_mode: false,
1455            merge_mode: MergeMode::LastRow,
1456            max_background_tasks: None,
1457            time_range: None,
1458            all_windows_inactive: false,
1459        };
1460        let compaction_region = compaction_region_with_expired_sst().await;
1461
1462        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1463
1464        assert!(output.outputs.is_empty());
1465        assert!(!output.expired_ssts.is_empty());
1466        assert!(output.expired_ssts.iter().all(|file| !file.compacting()));
1467    }
1468
1469    #[tokio::test]
1470    async fn test_expired_sst_does_not_determine_active_window() {
1471        let now = Timestamp::current_millis().value();
1472        let files = [
1473            FileMeta {
1474                file_id: FileId::random(),
1475                time_range: (
1476                    Timestamp::new_millisecond(0),
1477                    Timestamp::new_millisecond(10),
1478                ),
1479                level: 0,
1480                sequence: NonZeroU64::new(100),
1481                ..Default::default()
1482            },
1483            FileMeta {
1484                file_id: FileId::random(),
1485                time_range: (
1486                    Timestamp::new_millisecond(now - 1000),
1487                    Timestamp::new_millisecond(now),
1488                ),
1489                level: 0,
1490                sequence: NonZeroU64::new(1),
1491                ..Default::default()
1492            },
1493            FileMeta {
1494                file_id: FileId::random(),
1495                time_range: (
1496                    Timestamp::new_millisecond(now - 1000),
1497                    Timestamp::new_millisecond(now),
1498                ),
1499                level: 0,
1500                sequence: NonZeroU64::new(2),
1501                ..Default::default()
1502            },
1503        ];
1504        let compaction_region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
1505        let picker = TwcsPicker {
1506            trigger_file_num: 4,
1507            active_window_l1_merge_trigger: 8,
1508            inactive_window_trigger_file_num: 2,
1509            inactive_window_l1_merge_trigger: 8,
1510            time_window_seconds: Some(3),
1511            max_output_file_size: None,
1512            append_mode: false,
1513            merge_mode: MergeMode::LastRow,
1514            max_background_tasks: None,
1515            time_range: None,
1516            all_windows_inactive: false,
1517        };
1518
1519        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
1520
1521        assert_eq!(1, output.expired_ssts.len());
1522        assert!(output.outputs.is_empty());
1523    }
1524
1525    #[tokio::test]
1526    async fn test_active_window_survives_l0_consumption() {
1527        // Simulates the transient state right after an L0 compaction: level 0 is
1528        // empty and only L1 files remain. The active window must still be the
1529        // window of the most recent write (tracked by the max-sequence file), so
1530        // the window is NOT compacted under the inactive rules.
1531        let env = SchedulerEnv::new().await;
1532        let metadata = metadata_for_test();
1533        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
1534        let mut ssts = SstVersion::new(metadata.clone());
1535        ssts.add_files(
1536            Arc::new(crate::sst::file_purger::NoopFilePurger),
1537            [100, 101].into_iter().map(|sequence| FileMeta {
1538                region_id: metadata.region_id,
1539                file_id: FileId::random(),
1540                time_range: (
1541                    Timestamp::new_millisecond(0),
1542                    Timestamp::new_millisecond(10),
1543                ),
1544                level: 1,
1545                sequence: NonZeroU64::new(sequence),
1546                ..Default::default()
1547            }),
1548        );
1549        let compaction_region = CompactionRegion {
1550            region_id: metadata.region_id,
1551            region_options: RegionOptions::default(),
1552            engine_config: Arc::new(MitoConfig::default()),
1553            region_metadata: metadata.clone(),
1554            cache_manager: Arc::new(CacheManager::default()),
1555            access_layer: env.access_layer,
1556            manifest_ctx,
1557            current_version: CompactionVersion {
1558                metadata,
1559                options: RegionOptions::default(),
1560                ssts: Arc::new(ssts),
1561                memtable_min_sequence: None,
1562                compaction_time_window: None,
1563            },
1564            file_purger: None,
1565            ttl: None,
1566            max_parallelism: 1,
1567            plugins: Plugins::new(),
1568        };
1569        let picker = TwcsPicker {
1570            trigger_file_num: 4,
1571            active_window_l1_merge_trigger: 8,
1572            inactive_window_trigger_file_num: 2,
1573            inactive_window_l1_merge_trigger: 8,
1574            time_window_seconds: Some(3600),
1575            max_output_file_size: None,
1576            append_mode: false,
1577            merge_mode: MergeMode::LastRow,
1578            max_background_tasks: None,
1579            time_range: None,
1580            all_windows_inactive: false,
1581        };
1582
1583        assert!(picker.pick(&compaction_region).await.unwrap().is_none());
1584    }
1585
1586    #[test]
1587    fn test_find_active_window_by_sequence() {
1588        // The max-sequence file tracks the most recent write regardless of
1589        // level: here a compacted L1 file ending at 2999ms (window key 2) is
1590        // newer than the L0 file ending at 999ms (window key 0). Window keys
1591        // follow the assign_to_windows convention (truncate to seconds).
1592        let files = [
1593            new_file_handle_with_sequence(FileId::random(), 0, 999, 0, 1),
1594            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 1, 10),
1595        ];
1596        assert_eq!(Some(2), find_active_window_by_sequence(files.iter(), 1));
1597
1598        // Backfill: the newest write lands in an older window, and that window
1599        // is the active one.
1600        let files = [
1601            new_file_handle_with_sequence(FileId::random(), 0, 999, 0, 10),
1602            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 0, 1),
1603        ];
1604        assert_eq!(Some(0), find_active_window_by_sequence(files.iter(), 1));
1605
1606        // Files without a sequence are skipped; if none have one, the caller
1607        // falls back to the L0-based rule.
1608        let files = [new_file_handle_with_sequence(
1609            FileId::random(),
1610            0,
1611            999,
1612            1,
1613            0,
1614        )];
1615        assert_eq!(None, find_active_window_by_sequence(files.iter(), 1));
1616        assert!(find_active_window_by_sequence(Vec::<FileHandle>::new().iter(), 1).is_none());
1617    }
1618
1619    #[test]
1620    fn test_active_window_falls_back_to_l0_rule_without_sequence() {
1621        let active_window_of = |files: &[FileHandle]| {
1622            find_active_window_by_sequence(files.iter(), 1).or_else(|| {
1623                find_latest_window_in_seconds(files.iter().filter(|f| f.level() == 0), 1)
1624            })
1625        };
1626
1627        // Legacy files without sequence: the L0-based rule still applies.
1628        let files = [
1629            new_file_handle_with_sequence(FileId::random(), 0, 999, 1, 0),
1630            new_file_handle_with_sequence(FileId::random(), 2000, 2999, 0, 0),
1631        ];
1632        assert_eq!(Some(2), active_window_of(&files));
1633
1634        // No sequence and no L0: None, exactly as before.
1635        let files = [new_file_handle_with_sequence(
1636            FileId::random(),
1637            0,
1638            999,
1639            1,
1640            0,
1641        )];
1642        assert_eq!(None, active_window_of(&files));
1643    }
1644
1645    #[test]
1646    fn test_get_latest_window_in_seconds() {
1647        assert_eq!(
1648            Some(0),
1649            find_latest_window_in_seconds([new_file_handle(FileId::random(), 0, 999, 0)].iter(), 1)
1650        );
1651        assert_eq!(
1652            Some(1),
1653            find_latest_window_in_seconds(
1654                [new_file_handle(FileId::random(), 0, 1000, 0)].iter(),
1655                1
1656            )
1657        );
1658
1659        assert_eq!(
1660            Some(-9223372036857600),
1661            find_latest_window_in_seconds(
1662                [new_file_handle(FileId::random(), i64::MIN, i64::MIN + 1, 0)].iter(),
1663                3600,
1664            )
1665        );
1666
1667        assert_eq!(
1668            (i64::MAX / 10000000) * 10000,
1669            find_latest_window_in_seconds(
1670                [new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0)].iter(),
1671                10000,
1672            )
1673            .unwrap()
1674        );
1675
1676        assert_eq!(
1677            Some((i64::MAX / 3600000) * 3600),
1678            find_latest_window_in_seconds(
1679                [
1680                    new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0),
1681                    new_file_handle(FileId::random(), 0, 1000, 0)
1682                ]
1683                .iter(),
1684                3600
1685            )
1686        );
1687    }
1688
1689    #[test]
1690    fn test_window_assignment_and_activity_at_boundaries() {
1691        // Before, at, and after positive/negative boundaries, including the first
1692        // fractional second: all picker paths must agree with half-open partitions.
1693        for (unit, ticks_per_second) in [
1694            (TimeUnit::Second, 1),
1695            (TimeUnit::Millisecond, 1_000),
1696            (TimeUnit::Microsecond, 1_000_000),
1697            (TimeUnit::Nanosecond, 1_000_000_000),
1698        ] {
1699            for window_size in [1, 3, 86_400] {
1700                for boundary in [-window_size, 0, window_size] {
1701                    for (offset, expected_window) in [
1702                        (-1, boundary - window_size),
1703                        (0, boundary),
1704                        (ticks_per_second - 1, boundary),
1705                        (ticks_per_second, boundary + i64::from(window_size == 1)),
1706                    ] {
1707                        let timestamp = Timestamp::new(boundary * ticks_per_second + offset, unit);
1708                        let file = FileHandle::new(
1709                            FileMeta {
1710                                time_range: (timestamp, timestamp),
1711                                sequence: NonZeroU64::new(1),
1712                                ..Default::default()
1713                            },
1714                            crate::test_util::new_noop_file_purger(),
1715                        );
1716                        let files = [file];
1717                        let windows = assign_to_windows(files.iter(), window_size);
1718                        assert_eq!(
1719                            vec![expected_window],
1720                            windows.keys().copied().collect::<Vec<_>>(),
1721                            "{timestamp:?}, window size {window_size}"
1722                        );
1723                        assert_eq!(
1724                            Some(expected_window),
1725                            find_active_window_by_sequence(files.iter(), window_size)
1726                        );
1727                        assert_eq!(
1728                            Some(expected_window),
1729                            find_latest_window_in_seconds(files.iter(), window_size)
1730                        );
1731                    }
1732                }
1733            }
1734        }
1735    }
1736
1737    #[test]
1738    fn test_settling_window_at_timestamp_limits() {
1739        assert!(is_settling_window(0, Some(60), Some(60)));
1740        assert!(!is_settling_window(60, Some(60), Some(60)));
1741        assert!(!is_settling_window(-60, Some(60), Some(60)));
1742        assert!(!is_settling_window(0, None, Some(60)));
1743        // The distance between these windows does not fit in an i64.
1744        assert!(!is_settling_window(i64::MAX, Some(i64::MIN), Some(1)));
1745        assert!(!is_settling_window(i64::MIN, Some(i64::MAX), Some(1)));
1746    }
1747
1748    #[test]
1749    fn test_window_assignment_at_timestamp_limits() {
1750        for (seconds, expected_window) in [(i64::MIN, i64::MIN), (i64::MAX, i64::MAX - 1)] {
1751            let timestamp = Timestamp::new_second(seconds);
1752            let files = [FileHandle::new(
1753                FileMeta {
1754                    time_range: (timestamp, timestamp),
1755                    sequence: NonZeroU64::new(1),
1756                    ..Default::default()
1757                },
1758                crate::test_util::new_noop_file_purger(),
1759            )];
1760            let windows = assign_to_windows(files.iter(), 3);
1761            assert_eq!(
1762                vec![expected_window],
1763                windows.keys().copied().collect::<Vec<_>>()
1764            );
1765            assert_eq!(
1766                Some(expected_window),
1767                find_active_window_by_sequence(files.iter(), 3)
1768            );
1769            assert_eq!(
1770                Some(expected_window),
1771                find_latest_window_in_seconds(files.iter(), 3)
1772            );
1773            assert!(time_window_intersects_range(
1774                expected_window,
1775                3,
1776                &TimestampRange::single(timestamp)
1777            ));
1778        }
1779    }
1780
1781    #[tokio::test]
1782    async fn test_midnight_file_is_not_compacted_with_previous_day() {
1783        // Regression for #9441: flush already separates the midnight-only SST.
1784        let midnight = 86_400_000;
1785        CompactionPickerTestCase {
1786            window_size: 86_400,
1787            input_files: [
1788                (midnight - 240_000, midnight - 180_000),
1789                (midnight - 180_000, midnight - 90_000),
1790                (midnight - 90_000, midnight - 30_000),
1791                (midnight, midnight),
1792            ]
1793            .into_iter()
1794            .enumerate()
1795            .map(|(index, (start, end))| {
1796                new_file_handle_with_sequence(FileId::random(), start, end, 0, index as u64 + 1)
1797            })
1798            .collect(),
1799            expected_outputs: vec![ExpectedOutput {
1800                input_files: vec![0, 1, 2],
1801                output_level: 1,
1802            }],
1803        }
1804        .check()
1805        .await;
1806    }
1807
1808    #[test]
1809    fn test_assign_to_windows() {
1810        let windows = assign_to_windows(
1811            [
1812                new_file_handle(FileId::random(), 0, 999, 0),
1813                new_file_handle(FileId::random(), 0, 999, 0),
1814                new_file_handle(FileId::random(), 0, 999, 0),
1815                new_file_handle(FileId::random(), 0, 999, 0),
1816                new_file_handle(FileId::random(), 0, 999, 0),
1817            ]
1818            .iter(),
1819            3,
1820        );
1821        let fgs = &windows.get(&0).unwrap().files;
1822        assert_eq!(5, fgs.len());
1823
1824        let files = [FileId::random(); 3];
1825        let windows = assign_to_windows(
1826            [
1827                new_file_handle(files[0], -2000, -3, 0),
1828                new_file_handle(files[1], 0, 2999, 0),
1829                new_file_handle(files[2], 50, 10001, 0),
1830            ]
1831            .iter(),
1832            3,
1833        );
1834        assert_eq!(
1835            files[0],
1836            windows
1837                .get(&-3)
1838                .unwrap()
1839                .files()
1840                .next()
1841                .unwrap()
1842                .file_id()
1843                .file_id()
1844        );
1845        assert_eq!(
1846            files[1],
1847            windows
1848                .get(&0)
1849                .unwrap()
1850                .files()
1851                .next()
1852                .unwrap()
1853                .file_id()
1854                .file_id()
1855        );
1856        assert_eq!(
1857            files[2],
1858            windows
1859                .get(&9)
1860                .unwrap()
1861                .files()
1862                .next()
1863                .unwrap()
1864                .file_id()
1865                .file_id()
1866        );
1867    }
1868
1869    #[test]
1870    fn test_assign_files_to_windows() {
1871        let files = [
1872            FileId::random(),
1873            FileId::random(),
1874            FileId::random(),
1875            FileId::random(),
1876        ];
1877        let windows = assign_to_windows(
1878            [
1879                new_file_handle_with_sequence(files[0], 0, 999, 0, 1),
1880                new_file_handle_with_sequence(files[1], 0, 999, 0, 1),
1881                new_file_handle_with_sequence(files[2], 0, 999, 0, 2),
1882                new_file_handle_with_sequence(files[3], 0, 999, 0, 2),
1883            ]
1884            .iter(),
1885            3,
1886        );
1887        assert_eq!(windows.len(), 1);
1888        let window_files = &windows.get(&0).unwrap().files;
1889        assert_eq!(4, window_files.len());
1890        assert_eq!(
1891            window_files
1892                .iter()
1893                .map(|f| f.file_id().file_id())
1894                .collect::<HashSet<_>>(),
1895            files.into_iter().collect()
1896        );
1897    }
1898
1899    #[test]
1900    fn test_assign_compacting_to_windows() {
1901        let files = [
1902            new_file_handle(FileId::random(), 0, 999, 0),
1903            new_file_handle(FileId::random(), 0, 999, 0),
1904            new_file_handle(FileId::random(), 0, 999, 0),
1905            new_file_handle(FileId::random(), 0, 999, 0),
1906            new_file_handle(FileId::random(), 0, 999, 0),
1907        ];
1908        files[0].set_compacting(true);
1909        files[2].set_compacting(true);
1910        let mut windows = assign_to_windows(files.iter(), 3);
1911        let window0 = windows.remove(&0).unwrap();
1912        assert_eq!(3, window0.files.len());
1913        let candidates = window0
1914            .files
1915            .iter()
1916            .map(|f| f.file_id().file_id())
1917            .collect::<HashSet<_>>();
1918        assert_eq!(candidates.len(), 3);
1919        assert_eq!(
1920            candidates,
1921            [
1922                files[1].file_id().file_id(),
1923                files[3].file_id().file_id(),
1924                files[4].file_id().file_id()
1925            ]
1926            .into_iter()
1927            .collect::<HashSet<_>>()
1928        );
1929    }
1930
1931    /// (Window value, overlapping, files' time ranges in window)
1932    type ExpectedWindowSpec = (i64, bool, Vec<(i64, i64)>);
1933
1934    fn check_assign_to_windows_with_overlapping(
1935        file_time_ranges: &[(i64, i64)],
1936        time_window: i64,
1937        expected_files: &[ExpectedWindowSpec],
1938    ) {
1939        let files: Vec<_> = (0..file_time_ranges.len())
1940            .map(|_| FileId::random())
1941            .collect();
1942
1943        let file_handles = files
1944            .iter()
1945            .zip(file_time_ranges.iter())
1946            .map(|(file_id, range)| new_file_handle(*file_id, range.0, range.1, 0))
1947            .collect::<Vec<_>>();
1948
1949        let windows = assign_to_windows(file_handles.iter(), time_window);
1950        assert_eq!(expected_files.len(), windows.len());
1951
1952        for (expected_window, overlapping, window_files) in expected_files {
1953            let actual_window = windows.get(expected_window).unwrap();
1954            let actual_overlapping = window_has_overlap(actual_window, &windows);
1955            assert_eq!(*overlapping, actual_overlapping);
1956            let mut file_ranges = actual_window
1957                .files
1958                .iter()
1959                .map(|f| {
1960                    let (s, e) = f.time_range();
1961                    (s.value(), e.value())
1962                })
1963                .collect::<Vec<_>>();
1964            file_ranges.sort_unstable_by(|l, r| l.0.cmp(&r.0).then(l.1.cmp(&r.1)));
1965            assert_eq!(window_files, &file_ranges);
1966        }
1967    }
1968
1969    #[test]
1970    fn test_assign_to_windows_with_overlapping() {
1971        check_assign_to_windows_with_overlapping(
1972            &[(0, 999), (1000, 1999), (2000, 2999)],
1973            2,
1974            &[
1975                (0, false, vec![(0, 999), (1000, 1999)]),
1976                (2, false, vec![(2000, 2999)]),
1977            ],
1978        );
1979
1980        check_assign_to_windows_with_overlapping(
1981            &[(0, 1), (0, 999), (100, 2999)],
1982            2,
1983            &[
1984                (0, true, vec![(0, 1), (0, 999)]),
1985                (2, true, vec![(100, 2999)]),
1986            ],
1987        );
1988
1989        check_assign_to_windows_with_overlapping(
1990            &[(0, 999), (1000, 1999), (2000, 2999), (3000, 3999)],
1991            2,
1992            &[
1993                (0, false, vec![(0, 999), (1000, 1999)]),
1994                (2, false, vec![(2000, 2999), (3000, 3999)]),
1995            ],
1996        );
1997
1998        check_assign_to_windows_with_overlapping(
1999            &[
2000                (0, 999),
2001                (1000, 1999),
2002                (2000, 2999),
2003                (3000, 3999),
2004                (0, 3999),
2005            ],
2006            2,
2007            &[
2008                (0, true, vec![(0, 999), (1000, 1999)]),
2009                (2, true, vec![(0, 3999), (2000, 2999), (3000, 3999)]),
2010            ],
2011        );
2012
2013        check_assign_to_windows_with_overlapping(
2014            &[
2015                (0, 999),
2016                (1000, 1999),
2017                (2000, 2999),
2018                (3000, 3999),
2019                (1999, 3999),
2020            ],
2021            2,
2022            &[
2023                (0, true, vec![(0, 999), (1000, 1999)]),
2024                (2, true, vec![(1999, 3999), (2000, 2999), (3000, 3999)]),
2025            ],
2026        );
2027
2028        check_assign_to_windows_with_overlapping(
2029            &[
2030                (0, 999),     // window 0
2031                (1000, 1999), // window 0
2032                (2000, 2999), // window 2
2033                (3000, 3999), // window 2
2034                (2999, 3999), // window 2
2035            ],
2036            2,
2037            &[
2038                // Overlap within a window does not imply cross-window overlap.
2039                (0, false, vec![(0, 999), (1000, 1999)]),
2040                (2, false, vec![(2000, 2999), (2999, 3999), (3000, 3999)]),
2041            ],
2042        );
2043
2044        check_assign_to_windows_with_overlapping(
2045            &[
2046                (0, 999),     // window 0
2047                (1000, 1999), // window 0
2048                (2000, 2999), // window 2
2049                (3000, 3999), // window 2
2050                (0, 2000),    // window 2
2051            ],
2052            2,
2053            &[
2054                (0, true, vec![(0, 999), (1000, 1999)]),
2055                (2, true, vec![(0, 2000), (2000, 2999), (3000, 3999)]),
2056            ],
2057        );
2058    }
2059
2060    #[test]
2061    fn test_assign_to_windows_not_overlapping_when_pk_disjoint() {
2062        let files = [
2063            new_file_handle_with_size_sequence_and_primary_key_range(
2064                FileId::random(),
2065                0,
2066                1000,
2067                0,
2068                1,
2069                10,
2070                pk_range(b"a", b"f"),
2071            ),
2072            new_file_handle_with_size_sequence_and_primary_key_range(
2073                FileId::random(),
2074                500,
2075                2999,
2076                0,
2077                2,
2078                10,
2079                pk_range(b"x", b"z"),
2080            ),
2081        ];
2082
2083        let windows = assign_to_windows(files.iter(), 2);
2084
2085        assert_eq!(2, windows.len());
2086        let overlapping = window_has_overlap(windows.get(&0).unwrap(), &windows);
2087        assert!(!overlapping);
2088    }
2089
2090    #[test]
2091    fn test_assign_to_windows_pk_unknown_in_earlier_window_does_not_poison_later_windows() {
2092        let files = [
2093            new_file_handle(FileId::random(), 0, 1999, 0),
2094            new_file_handle_with_size_sequence_and_primary_key_range(
2095                FileId::random(),
2096                2000,
2097                3999,
2098                0,
2099                1,
2100                10,
2101                pk_range(b"a", b"f"),
2102            ),
2103            new_file_handle_with_size_sequence_and_primary_key_range(
2104                FileId::random(),
2105                3000,
2106                4999,
2107                0,
2108                2,
2109                10,
2110                pk_range(b"x", b"z"),
2111            ),
2112        ];
2113
2114        let windows = assign_to_windows(files.iter(), 2);
2115
2116        let overlapping = window_has_overlap(windows.get(&4).unwrap(), &windows);
2117        assert!(!overlapping);
2118    }
2119
2120    struct CompactionPickerTestCase {
2121        window_size: i64,
2122        input_files: Vec<FileHandle>,
2123        expected_outputs: Vec<ExpectedOutput>,
2124    }
2125
2126    impl CompactionPickerTestCase {
2127        async fn check(&self) {
2128            let file_id_to_idx = self
2129                .input_files
2130                .iter()
2131                .enumerate()
2132                .map(|(idx, file)| (file.file_id(), idx))
2133                .collect::<HashMap<_, _>>();
2134            let windows = assign_to_windows(self.input_files.iter(), self.window_size);
2135            let active_window =
2136                find_active_window_by_sequence(self.input_files.iter(), self.window_size).or_else(
2137                    || find_latest_window_in_seconds(self.input_files.iter(), self.window_size),
2138                );
2139            let output = TwcsPicker {
2140                trigger_file_num: 2,
2141                active_window_l1_merge_trigger: 8,
2142                inactive_window_trigger_file_num: 2,
2143                inactive_window_l1_merge_trigger: 2,
2144                time_window_seconds: None,
2145                max_output_file_size: None,
2146                append_mode: false,
2147                merge_mode: MergeMode::LastRow,
2148                max_background_tasks: None,
2149                time_range: None,
2150                all_windows_inactive: false,
2151            }
2152            .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
2153            .await
2154            .unwrap();
2155
2156            let output = output
2157                .iter()
2158                .map(|o| {
2159                    let input_file_ids = o
2160                        .inputs
2161                        .iter()
2162                        .map(|f| file_id_to_idx.get(&f.file_id()).copied().unwrap())
2163                        .collect::<HashSet<_>>();
2164                    (input_file_ids, o.output_level)
2165                })
2166                .collect::<Vec<_>>();
2167
2168            let expected = self
2169                .expected_outputs
2170                .iter()
2171                .map(|o| {
2172                    let input_file_ids = o.input_files.iter().copied().collect::<HashSet<_>>();
2173                    (input_file_ids, o.output_level)
2174                })
2175                .collect::<Vec<_>>();
2176            assert_eq!(expected, output);
2177        }
2178    }
2179
2180    struct ExpectedOutput {
2181        input_files: Vec<usize>,
2182        output_level: Level,
2183    }
2184
2185    #[tokio::test]
2186    async fn test_newer_windows_are_placed_last_for_execution() {
2187        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2188
2189        // Case 1: 2 runs found in each time window.
2190        CompactionPickerTestCase {
2191            window_size: 3,
2192            input_files: [
2193                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
2194                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
2195                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3), //active windows
2196                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4), //active windows
2197            ]
2198            .to_vec(),
2199            expected_outputs: vec![
2200                ExpectedOutput {
2201                    input_files: vec![0, 1],
2202                    output_level: 1,
2203                },
2204                ExpectedOutput {
2205                    input_files: vec![2, 3],
2206                    output_level: 1,
2207                },
2208            ],
2209        }
2210        .check()
2211        .await;
2212
2213        // Case 2:
2214        //    -2000........-3
2215        // -3000.....-100
2216        //                    0..............2999
2217        //                      50..........2998
2218        //                     11.........2990
2219        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
2220        CompactionPickerTestCase {
2221            window_size: 3,
2222            input_files: [
2223                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
2224                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
2225                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3),
2226                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4),
2227                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 5),
2228            ]
2229            .to_vec(),
2230            expected_outputs: vec![
2231                ExpectedOutput {
2232                    input_files: vec![0, 1],
2233                    output_level: 1,
2234                },
2235                ExpectedOutput {
2236                    input_files: vec![2, 3, 4],
2237                    output_level: 1,
2238                },
2239            ],
2240        }
2241        .check()
2242        .await;
2243
2244        // Case 3:
2245        // A compaction may split output into several files that have overlapping time ranges and
2246        // the same sequence. They are ordinary compaction candidates now: the picker merges the
2247        // overlapping ones in the window that reaches the trigger.
2248        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
2249        CompactionPickerTestCase {
2250            window_size: 3,
2251            input_files: [
2252                new_file_handle_with_sequence(file_ids[0], 0, 2999, 1, 1),
2253                new_file_handle_with_sequence(file_ids[1], 0, 2998, 1, 1),
2254                new_file_handle_with_sequence(file_ids[2], 3000, 5999, 1, 2),
2255                new_file_handle_with_sequence(file_ids[3], 3000, 5000, 1, 2),
2256                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 3),
2257            ]
2258            .to_vec(),
2259            expected_outputs: vec![ExpectedOutput {
2260                input_files: vec![2, 3],
2261                output_level: 1,
2262            }],
2263        }
2264        .check()
2265        .await;
2266    }
2267
2268    #[tokio::test]
2269    async fn test_build_output_skips_pk_disjoint_files() {
2270        let files = [
2271            new_file_handle_with_size_sequence_and_primary_key_range(
2272                FileId::random(),
2273                0,
2274                2999,
2275                0,
2276                1,
2277                10,
2278                pk_range(b"a", b"f"),
2279            ),
2280            new_file_handle_with_size_sequence_and_primary_key_range(
2281                FileId::random(),
2282                50,
2283                2998,
2284                0,
2285                2,
2286                10,
2287                pk_range(b"x", b"z"),
2288            ),
2289        ];
2290        let windows = assign_to_windows(files.iter(), 3);
2291        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2292        let output = TwcsPicker {
2293            trigger_file_num: 4,
2294            active_window_l1_merge_trigger: 8,
2295            inactive_window_trigger_file_num: 4,
2296            inactive_window_l1_merge_trigger: 8,
2297            time_window_seconds: None,
2298            max_output_file_size: None,
2299            append_mode: false,
2300            merge_mode: MergeMode::LastRow,
2301            max_background_tasks: None,
2302            time_range: None,
2303            all_windows_inactive: false,
2304        }
2305        .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
2306        .await
2307        .unwrap();
2308
2309        assert!(output.is_empty());
2310    }
2311
2312    #[test]
2313    fn test_append_mode_filter_large_files() {
2314        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2315        let max_output_file_size = 1000u64;
2316
2317        // Create files with different sizes
2318        let small_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 500);
2319        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 1500);
2320        let small_file_2 = new_file_handle_with_size_and_sequence(file_ids[2], 0, 999, 0, 3, 800);
2321        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[3], 0, 999, 0, 4, 2000);
2322
2323        let mut files_to_merge = vec![small_file_1, large_file_1, small_file_2, large_file_2];
2324
2325        // Test filtering logic directly
2326        let original_count = files_to_merge.len();
2327
2328        // Apply append mode filtering
2329        files_to_merge.retain(|file| file.size() <= max_output_file_size);
2330
2331        // Should have filtered out 2 large files, leaving 2 small files
2332        assert_eq!(files_to_merge.len(), 2);
2333        assert_eq!(original_count, 4);
2334
2335        // Verify the remaining files are the small ones
2336        for file in &files_to_merge {
2337            assert!(
2338                file.size() <= max_output_file_size,
2339                "File size {} should be <= {}",
2340                file.size(),
2341                max_output_file_size
2342            );
2343        }
2344    }
2345
2346    #[tokio::test]
2347    async fn test_build_output_multiple_windows_with_zero_runs() {
2348        let file_ids = (0..7).map(|_| FileId::random()).collect::<Vec<_>>();
2349
2350        let files = [
2351            // Window 0: Contains 3 files but not forming any runs (not enough files in sequence to reach trigger_file_num)
2352            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2353            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2354            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2355            // Window 3: Contains files that will form 2 runs
2356            new_file_handle_with_sequence(file_ids[3], 3000, 3999, 0, 4),
2357            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
2358            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
2359            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
2360        ];
2361
2362        let windows = assign_to_windows(files.iter(), 3);
2363
2364        // Create picker with trigger_file_num of 4 so single files won't form runs in first window
2365        let picker = TwcsPicker {
2366            trigger_file_num: 4, // High enough to prevent runs in first window
2367            active_window_l1_merge_trigger: 8,
2368            inactive_window_trigger_file_num: 4,
2369            inactive_window_l1_merge_trigger: 8,
2370            time_window_seconds: Some(3),
2371            max_output_file_size: None,
2372            append_mode: false,
2373            merge_mode: MergeMode::LastRow,
2374            max_background_tasks: None,
2375            time_range: None,
2376            all_windows_inactive: false,
2377        };
2378
2379        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2380        let output = picker
2381            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2382            .await
2383            .unwrap();
2384
2385        assert!(
2386            !output.is_empty(),
2387            "Should have output from windows with runs, even when one window has 0 runs"
2388        );
2389
2390        let all_output_files: Vec<_> = output
2391            .iter()
2392            .flat_map(|o| o.inputs.iter())
2393            .map(|f| f.file_id().file_id())
2394            .collect();
2395
2396        assert!(
2397            all_output_files.contains(&file_ids[3])
2398                || all_output_files.contains(&file_ids[4])
2399                || all_output_files.contains(&file_ids[5]),
2400            "Output should contain files from the window with runs"
2401        );
2402    }
2403
2404    #[tokio::test]
2405    async fn test_build_output_single_window_zero_runs() {
2406        let file_ids = (0..2).map(|_| FileId::random()).collect::<Vec<_>>();
2407
2408        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 2000); // 2000 bytes
2409        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 2500); // 2500 bytes
2410
2411        let files = [large_file_1, large_file_2];
2412
2413        let windows = assign_to_windows(files.iter(), 3);
2414
2415        let picker = TwcsPicker {
2416            trigger_file_num: 2,
2417            active_window_l1_merge_trigger: 8,
2418            inactive_window_trigger_file_num: 2,
2419            inactive_window_l1_merge_trigger: 8,
2420            time_window_seconds: Some(3),
2421            max_output_file_size: Some(1000),
2422            append_mode: true,
2423            merge_mode: MergeMode::LastRow,
2424            max_background_tasks: None,
2425            time_range: None,
2426            all_windows_inactive: false,
2427        };
2428
2429        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2430        let output = picker
2431            .build_output_with_time_range(RegionId::from_u64(456), windows, active_window, None)
2432            .await
2433            .unwrap();
2434
2435        // Should return empty output (no compaction needed)
2436        assert!(
2437            output.is_empty(),
2438            "Should return empty output when no runs are found after filtering"
2439        );
2440    }
2441
2442    #[tokio::test]
2443    async fn test_append_mode_can_pick_remaining_single_level_files() {
2444        let files = [
2445            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 100),
2446            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 100),
2447            new_file_handle_with_size_and_sequence(FileId::random(), 40, 49, 0, 3, 100),
2448            new_file_handle_with_size_and_sequence(FileId::random(), 60, 69, 0, 4, 2_000),
2449        ];
2450        let windows = assign_to_windows(files.iter(), 1);
2451        let picker = TwcsPicker {
2452            trigger_file_num: 4,
2453            active_window_l1_merge_trigger: 8,
2454            inactive_window_trigger_file_num: 4,
2455            inactive_window_l1_merge_trigger: 8,
2456            time_window_seconds: Some(1),
2457            max_output_file_size: Some(1_000),
2458            append_mode: true,
2459            merge_mode: MergeMode::LastRow,
2460            max_background_tasks: None,
2461            time_range: None,
2462            all_windows_inactive: false,
2463        };
2464
2465        let output = picker
2466            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
2467            .await
2468            .unwrap();
2469
2470        assert_eq!(1, output.len());
2471        assert_eq!(3, output[0].inputs.len());
2472    }
2473
2474    #[tokio::test]
2475    async fn test_max_background_tasks_truncation() {
2476        let file_ids = (0..10).map(|_| FileId::random()).collect::<Vec<_>>();
2477        let max_background_tasks = 3;
2478
2479        // Create files across multiple windows that will generate multiple compaction outputs
2480        let files = [
2481            // Window 0: 4 files that will form a run
2482            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2483            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2484            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2485            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
2486            // Window 3: 4 files that will form another run
2487            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
2488            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
2489            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
2490            new_file_handle_with_sequence(file_ids[7], 3000, 3999, 0, 8),
2491            // Window 6: 4 files that will form another run
2492            new_file_handle_with_sequence(file_ids[8], 6000, 6999, 0, 9),
2493            new_file_handle_with_sequence(file_ids[9], 6000, 6999, 0, 10),
2494        ];
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            merge_mode: MergeMode::LastRow,
2507            max_background_tasks: Some(max_background_tasks),
2508            time_range: None,
2509            all_windows_inactive: false,
2510        };
2511
2512        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2513        let output = picker
2514            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2515            .await
2516            .unwrap();
2517
2518        // Should have at most max_background_tasks outputs
2519        assert!(
2520            output.len() <= max_background_tasks,
2521            "Output should be truncated to max_background_tasks: expected <= {}, got {}",
2522            max_background_tasks,
2523            output.len()
2524        );
2525
2526        // Without max_background_tasks, should have more outputs
2527        let picker_no_limit = TwcsPicker {
2528            trigger_file_num: 4,
2529            active_window_l1_merge_trigger: 8,
2530            inactive_window_trigger_file_num: 4,
2531            inactive_window_l1_merge_trigger: 8,
2532            time_window_seconds: Some(3),
2533            max_output_file_size: None,
2534            append_mode: false,
2535            merge_mode: MergeMode::LastRow,
2536            max_background_tasks: None,
2537            time_range: None,
2538            all_windows_inactive: false,
2539        };
2540
2541        let windows_no_limit = assign_to_windows(files.iter(), 3);
2542        let output_no_limit = picker_no_limit
2543            .build_output_with_time_range(
2544                RegionId::from_u64(123),
2545                windows_no_limit,
2546                active_window,
2547                None,
2548            )
2549            .await
2550            .unwrap();
2551
2552        // Without limit, should have more outputs (if there are enough windows)
2553        if output_no_limit.len() > max_background_tasks {
2554            assert!(
2555                output_no_limit.len() > output.len(),
2556                "Without limit should have more outputs than with limit"
2557            );
2558        }
2559    }
2560
2561    #[tokio::test]
2562    async fn test_max_background_tasks_no_truncation_when_under_limit() {
2563        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
2564        let max_background_tasks = 10; // Larger than expected outputs
2565
2566        // Create files in one window that will generate one compaction output
2567        let files = [
2568            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
2569            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
2570            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
2571            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
2572        ];
2573
2574        let windows = assign_to_windows(files.iter(), 3);
2575
2576        let picker = TwcsPicker {
2577            trigger_file_num: 4,
2578            active_window_l1_merge_trigger: 8,
2579            inactive_window_trigger_file_num: 4,
2580            inactive_window_l1_merge_trigger: 8,
2581            time_window_seconds: Some(3),
2582            max_output_file_size: None,
2583            append_mode: false,
2584            merge_mode: MergeMode::LastRow,
2585            max_background_tasks: Some(max_background_tasks),
2586            time_range: None,
2587            all_windows_inactive: false,
2588        };
2589
2590        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2591        let output = picker
2592            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2593            .await
2594            .unwrap();
2595
2596        // Should have all outputs since we're under the limit
2597        assert!(
2598            output.len() <= max_background_tasks,
2599            "Output should be within limit"
2600        );
2601        // Should have at least one output
2602        assert!(!output.is_empty(), "Should have at least one output");
2603    }
2604
2605    #[tokio::test]
2606    async fn test_pick_multiple_runs() {
2607        common_telemetry::init_default_ut_logging();
2608
2609        let num_files = 8;
2610        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
2611
2612        // Create files with different sequences so they form multiple runs
2613        let files: Vec<_> = file_ids
2614            .iter()
2615            .enumerate()
2616            .map(|(idx, file_id)| {
2617                new_file_handle_with_size_and_sequence(
2618                    *file_id,
2619                    0,
2620                    999,
2621                    0,
2622                    (idx + 1) as u64,
2623                    1024 * 1024,
2624                )
2625            })
2626            .collect();
2627
2628        let windows = assign_to_windows(files.iter(), 3);
2629
2630        let picker = TwcsPicker {
2631            trigger_file_num: 4,
2632            active_window_l1_merge_trigger: 8,
2633            inactive_window_trigger_file_num: 4,
2634            inactive_window_l1_merge_trigger: 8,
2635            time_window_seconds: Some(3),
2636            max_output_file_size: None,
2637            append_mode: false,
2638            merge_mode: MergeMode::LastRow,
2639            max_background_tasks: None,
2640            time_range: None,
2641            all_windows_inactive: false,
2642        };
2643
2644        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2645        let output = picker
2646            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2647            .await
2648            .unwrap();
2649
2650        assert_eq!(1, output.len());
2651        assert_eq!(output[0].inputs.len(), num_files);
2652    }
2653
2654    #[tokio::test]
2655    async fn test_window_trigger_can_exceed_input_limit() {
2656        common_telemetry::init_default_ut_logging();
2657
2658        let num_files = 50;
2659        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
2660
2661        // Create files with different sequences so they form 2 runs
2662        let files: Vec<_> = file_ids
2663            .iter()
2664            .enumerate()
2665            .map(|(idx, file_id)| {
2666                new_file_handle_with_size_and_sequence(
2667                    *file_id,
2668                    (idx / 2 * 10) as i64,
2669                    (idx / 2 * 10 + 5) as i64,
2670                    0,
2671                    (idx + 1) as u64,
2672                    1024 * 1024,
2673                )
2674            })
2675            .collect();
2676
2677        let windows = assign_to_windows(files.iter(), 3);
2678
2679        let picker = TwcsPicker {
2680            trigger_file_num: num_files,
2681            active_window_l1_merge_trigger: 8,
2682            inactive_window_trigger_file_num: num_files,
2683            inactive_window_l1_merge_trigger: 8,
2684            time_window_seconds: Some(3),
2685            max_output_file_size: None,
2686            append_mode: false,
2687            merge_mode: MergeMode::LastRow,
2688            max_background_tasks: None,
2689            time_range: None,
2690            all_windows_inactive: false,
2691        };
2692
2693        let active_window = find_latest_window_in_seconds(files.iter(), 3);
2694        let output = picker
2695            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2696            .await
2697            .unwrap();
2698
2699        assert_eq!(1, output.len());
2700        assert_eq!(output[0].inputs.len(), num_files.min(*MAX_INPUT_FILES));
2701    }
2702
2703    #[tokio::test]
2704    async fn test_limit_max_input_files_keeps_deletion_markers() {
2705        common_telemetry::init_default_ut_logging();
2706
2707        // One large file group spanning the whole window plus enough small ones to fill the
2708        // input limit. That is exactly 2 runs, so the window allows filtering deletions.
2709        let mut files = vec![new_file_handle_with_size_and_sequence(
2710            FileId::random(),
2711            0,
2712            3_000_000,
2713            0,
2714            1,
2715            1024 * 1024 * 1024,
2716        )];
2717        files.extend((0..DEFAULT_MAX_INPUT_FILES as i64).map(|idx| {
2718            new_file_handle_with_size_and_sequence(
2719                FileId::random(),
2720                (idx + 1) * 10_000,
2721                (idx + 1) * 10_000 + 1_000,
2722                0,
2723                (idx + 2) as u64,
2724                1024,
2725            )
2726        }));
2727
2728        let windows = assign_to_windows(files.iter(), 3600);
2729
2730        let picker = TwcsPicker {
2731            trigger_file_num: 4,
2732            active_window_l1_merge_trigger: 8,
2733            inactive_window_trigger_file_num: 4,
2734            inactive_window_l1_merge_trigger: 8,
2735            time_window_seconds: Some(3600),
2736            max_output_file_size: None,
2737            append_mode: false,
2738            merge_mode: MergeMode::LastRow,
2739            max_background_tasks: None,
2740            time_range: None,
2741            all_windows_inactive: false,
2742        };
2743
2744        let active_window = find_latest_window_in_seconds(files.iter(), 3600);
2745        let output = picker
2746            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2747            .await
2748            .unwrap();
2749
2750        assert_eq!(1, output.len());
2751        // The input file num limit picks the smallest groups first, so the large group is
2752        // left behind while the small groups that overlap it are compacted.
2753        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2754        assert!(
2755            !output[0].filter_deleted,
2756            "deletion markers must be kept once the file num limit drops files they may mask"
2757        );
2758    }
2759
2760    #[tokio::test]
2761    async fn test_limit_max_input_files_still_filters_without_overlap() {
2762        common_telemetry::init_default_ut_logging();
2763
2764        // More disjoint file groups than the input limit, i.e. a single run. Nothing left
2765        // behind can hold a row masked by a deletion marker we compact.
2766        let files: Vec<_> = (0..DEFAULT_MAX_INPUT_FILES as i64 + 8)
2767            .map(|idx| {
2768                new_file_handle_with_size_and_sequence(
2769                    FileId::random(),
2770                    (idx + 1) * 10_000,
2771                    (idx + 1) * 10_000 + 1_000,
2772                    0,
2773                    (idx + 1) as u64,
2774                    1024,
2775                )
2776            })
2777            .collect();
2778
2779        let windows = assign_to_windows(files.iter(), 3600);
2780
2781        let picker = TwcsPicker {
2782            trigger_file_num: 4,
2783            active_window_l1_merge_trigger: 8,
2784            inactive_window_trigger_file_num: 4,
2785            inactive_window_l1_merge_trigger: 8,
2786            time_window_seconds: Some(3600),
2787            max_output_file_size: Some(1024 * 1024 * 1024),
2788            append_mode: false,
2789            merge_mode: MergeMode::LastRow,
2790            max_background_tasks: None,
2791            time_range: None,
2792            all_windows_inactive: false,
2793        };
2794
2795        let active_window = find_latest_window_in_seconds(files.iter(), 3600);
2796        let output = picker
2797            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
2798            .await
2799            .unwrap();
2800
2801        assert_eq!(1, output.len());
2802        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
2803        assert!(output[0].filter_deleted);
2804    }
2805
2806    #[tokio::test]
2807    async fn test_newer_windows_have_priority() {
2808        let older_file_ids = [FileId::random(), FileId::random()];
2809        let newer_file_ids = [FileId::random(), FileId::random()];
2810        let files = [
2811            new_file_handle_with_sequence(older_file_ids[0], 1_000, 1_999, 0, 1),
2812            new_file_handle_with_sequence(older_file_ids[1], 1_000, 1_999, 0, 2),
2813            new_file_handle_with_sequence(newer_file_ids[0], 7_000, 7_999, 0, 3),
2814            new_file_handle_with_sequence(newer_file_ids[1], 7_000, 7_999, 0, 4),
2815        ];
2816        let windows = assign_to_windows(files.iter(), 3);
2817        let picker = TwcsPicker {
2818            trigger_file_num: 2,
2819            active_window_l1_merge_trigger: 8,
2820            inactive_window_trigger_file_num: 2,
2821            inactive_window_l1_merge_trigger: 8,
2822            time_window_seconds: Some(3),
2823            max_output_file_size: None,
2824            append_mode: false,
2825            merge_mode: MergeMode::LastRow,
2826            max_background_tasks: Some(1),
2827            time_range: None,
2828            all_windows_inactive: false,
2829        };
2830
2831        let output = picker
2832            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(6), None)
2833            .await
2834            .unwrap();
2835
2836        assert_eq!(1, output.len());
2837        assert_eq!(
2838            newer_file_ids.into_iter().collect::<HashSet<_>>(),
2839            output[0]
2840                .inputs
2841                .iter()
2842                .map(|file| file.file_id().file_id())
2843                .collect::<HashSet<_>>()
2844        );
2845    }
2846
2847    #[test]
2848    fn test_filter_time_windows_by_time_range() {
2849        // Interior, exclusive boundary ends, fractional boundary ends, negative
2850        // timestamps, and empty ranges must all use the same half-open windows.
2851        for (start, end, expected_windows) in [
2852            (1_200, 1_800, vec![0]),
2853            (0, 3_000, vec![0]),
2854            (3_000, 6_000, vec![3]),
2855            (2_999, 3_000, vec![0]),
2856            (2_999, 3_001, vec![0, 3]),
2857            (3_000, 3_001, vec![3]),
2858            (3_001, 3_002, vec![3]),
2859            (-3_000, 0, vec![-3]),
2860            (-3_001, -2_999, vec![-6, -3]),
2861            (0, 0, vec![]),
2862            (1_500, 1_500, vec![]),
2863        ] {
2864            let time_range = TimestampRange::with_unit(start, end, TimeUnit::Millisecond).unwrap();
2865            let selected = [-6, -3, 0, 3, 6]
2866                .into_iter()
2867                .filter(|window| time_window_intersects_range(*window, 3, &time_range))
2868                .collect::<Vec<_>>();
2869            assert_eq!(expected_windows, selected, "{time_range}");
2870        }
2871    }
2872
2873    #[test]
2874    fn test_filter_time_windows_with_unbounded_and_extreme_ranges() {
2875        for (range, expected_windows) in [
2876            (TimestampRange::min_to_max(), vec![-3, 0, 3]),
2877            (
2878                TimestampRange::from_start(Timestamp::new_second(0)),
2879                vec![0, 3],
2880            ),
2881            (
2882                TimestampRange::until_end(Timestamp::new_second(0), false),
2883                vec![-3],
2884            ),
2885        ] {
2886            let selected = [-3, 0, 3]
2887                .into_iter()
2888                .filter(|window| time_window_intersects_range(*window, 3, &range))
2889                .collect::<Vec<_>>();
2890            assert_eq!(expected_windows, selected, "{range}");
2891        }
2892
2893        let upper_range = TimestampRange::new(
2894            Timestamp::new_second(i64::MAX - 1),
2895            Timestamp::new_second(i64::MAX),
2896        )
2897        .unwrap();
2898        assert!(!time_window_intersects_range(0, 4, &upper_range));
2899        assert!(time_window_intersects_range(i64::MAX - 3, 4, &upper_range));
2900
2901        let lower_range = TimestampRange::until_end(Timestamp::new_second(i64::MIN), false);
2902        assert!(!time_window_intersects_range(i64::MIN, 3, &lower_range));
2903    }
2904
2905    #[tokio::test]
2906    async fn test_time_range_filter_precedes_background_task_limit() {
2907        let early_file_ids = [FileId::random(), FileId::random()];
2908        let selected_file_ids = [FileId::random(), FileId::random()];
2909        let files = [
2910            new_file_handle_with_sequence(early_file_ids[0], 1_000, 1_999, 0, 1),
2911            new_file_handle_with_sequence(early_file_ids[1], 1_000, 1_999, 0, 2),
2912            new_file_handle_with_sequence(selected_file_ids[0], 7_000, 7_999, 0, 3),
2913            new_file_handle_with_sequence(selected_file_ids[1], 7_000, 7_999, 0, 4),
2914        ];
2915        let windows = assign_to_windows(files.iter(), 3);
2916        let picker = TwcsPicker {
2917            trigger_file_num: 2,
2918            active_window_l1_merge_trigger: 8,
2919            inactive_window_trigger_file_num: 2,
2920            inactive_window_l1_merge_trigger: 8,
2921            time_window_seconds: Some(3),
2922            max_output_file_size: None,
2923            append_mode: false,
2924            merge_mode: MergeMode::LastRow,
2925            max_background_tasks: Some(1),
2926            time_range: TimestampRange::new(
2927                Timestamp::new_millisecond(7_200),
2928                Timestamp::new_millisecond(7_800),
2929            ),
2930            all_windows_inactive: false,
2931        };
2932
2933        let output = picker
2934            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(6), Some(3))
2935            .await
2936            .unwrap();
2937
2938        assert_eq!(1, output.len());
2939        assert_eq!(
2940            selected_file_ids.into_iter().collect::<HashSet<_>>(),
2941            output[0]
2942                .inputs
2943                .iter()
2944                .map(|file| file.file_id().file_id())
2945                .collect::<HashSet<_>>()
2946        );
2947    }
2948
2949    #[tokio::test]
2950    async fn test_inactive_window_uses_its_trigger_file_num() {
2951        let files = [
2952            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2953            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
2954        ];
2955        let windows = assign_to_windows(files.iter(), 100);
2956        let picker = TwcsPicker {
2957            trigger_file_num: 4,
2958            active_window_l1_merge_trigger: 8,
2959            inactive_window_trigger_file_num: 2,
2960            inactive_window_l1_merge_trigger: 8,
2961            time_window_seconds: Some(100),
2962            max_output_file_size: None,
2963            append_mode: false,
2964            merge_mode: MergeMode::LastRow,
2965            max_background_tasks: None,
2966            time_range: None,
2967            all_windows_inactive: false,
2968        };
2969
2970        let output = picker
2971            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
2972            .await
2973            .unwrap();
2974
2975        assert_eq!(1, output.len());
2976        assert_eq!(2, output[0].inputs.len());
2977    }
2978
2979    #[tokio::test]
2980    async fn test_inactive_window_requires_a_level_trigger_before_mixed_fallback() {
2981        let files = [
2982            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
2983            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 1, 2, 10),
2984        ];
2985        let windows = assign_to_windows(files.iter(), 100);
2986        let picker = TwcsPicker {
2987            trigger_file_num: 4,
2988            active_window_l1_merge_trigger: 8,
2989            inactive_window_trigger_file_num: 8,
2990            inactive_window_l1_merge_trigger: 2,
2991            time_window_seconds: Some(100),
2992            max_output_file_size: Some(1_000),
2993            append_mode: false,
2994            merge_mode: MergeMode::LastRow,
2995            max_background_tasks: None,
2996            time_range: None,
2997            all_windows_inactive: false,
2998        };
2999
3000        let output = picker
3001            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3002            .await
3003            .unwrap();
3004
3005        assert!(output.is_empty());
3006    }
3007
3008    #[tokio::test]
3009    async fn test_count_first_prefers_more_files_over_smaller_overlap() {
3010        let files = [
3011            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
3012            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
3013            new_file_handle_with_size_and_sequence(FileId::random(), 40, 50, 0, 3, 10),
3014            new_file_handle_with_size_and_sequence(FileId::random(), 5, 15, 0, 4, 10),
3015        ];
3016        let windows = assign_to_windows(files.iter(), 100);
3017        let picker = TwcsPicker {
3018            trigger_file_num: 2,
3019            active_window_l1_merge_trigger: 8,
3020            inactive_window_trigger_file_num: 2,
3021            inactive_window_l1_merge_trigger: 8,
3022            time_window_seconds: Some(100),
3023            max_output_file_size: None,
3024            append_mode: false,
3025            merge_mode: MergeMode::LastRow,
3026            max_background_tasks: None,
3027            time_range: None,
3028            all_windows_inactive: false,
3029        };
3030
3031        let output = picker
3032            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3033            .await
3034            .unwrap();
3035
3036        assert_eq!(1, output.len());
3037        assert_eq!(4, output[0].inputs.len());
3038    }
3039
3040    #[tokio::test]
3041    async fn test_count_first_trigger_counts_physical_ssts() {
3042        let files = [
3043            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
3044            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, 1, 10),
3045            new_file_handle_with_size_and_sequence(FileId::random(), 20, 30, 0, 2, 10),
3046        ];
3047        let windows = assign_to_windows(files.iter(), 100);
3048        let picker = TwcsPicker {
3049            trigger_file_num: 3,
3050            active_window_l1_merge_trigger: 8,
3051            inactive_window_trigger_file_num: 3,
3052            inactive_window_l1_merge_trigger: 8,
3053            time_window_seconds: Some(100),
3054            max_output_file_size: None,
3055            append_mode: false,
3056            merge_mode: MergeMode::LastRow,
3057            max_background_tasks: None,
3058            time_range: None,
3059            all_windows_inactive: false,
3060        };
3061
3062        let output = picker
3063            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3064            .await
3065            .unwrap();
3066
3067        assert_eq!(1, output.len());
3068        assert_eq!(3, output[0].inputs.len());
3069    }
3070
3071    #[tokio::test]
3072    async fn test_count_first_does_not_compact_overlap_below_trigger() {
3073        let files = [
3074            new_file_handle_with_size_and_sequence(FileId::random(), 0, 20, 0, 1, 10),
3075            new_file_handle_with_size_and_sequence(FileId::random(), 10, 30, 0, 2, 10),
3076        ];
3077        let windows = assign_to_windows(files.iter(), 100);
3078        let picker = TwcsPicker {
3079            trigger_file_num: 3,
3080            active_window_l1_merge_trigger: 8,
3081            inactive_window_trigger_file_num: 3,
3082            inactive_window_l1_merge_trigger: 8,
3083            time_window_seconds: Some(100),
3084            max_output_file_size: None,
3085            append_mode: false,
3086            merge_mode: MergeMode::LastRow,
3087            max_background_tasks: None,
3088            time_range: None,
3089            all_windows_inactive: false,
3090        };
3091
3092        let output = picker
3093            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3094            .await
3095            .unwrap();
3096
3097        assert!(output.is_empty());
3098    }
3099
3100    #[tokio::test]
3101    async fn test_filter_deleted_is_false_when_selected_files_overlap_unselected_file() {
3102        let mut files = (0..32)
3103            .map(|idx| {
3104                new_file_handle_with_size_and_sequence(
3105                    FileId::random(),
3106                    idx * 10,
3107                    idx * 10 + 9,
3108                    0,
3109                    idx as u64 + 1,
3110                    10,
3111                )
3112            })
3113            .collect::<Vec<_>>();
3114        files.push(new_file_handle_with_size_and_sequence(
3115            FileId::random(),
3116            0,
3117            320,
3118            0,
3119            33,
3120            10,
3121        ));
3122        let windows = assign_to_windows(files.iter(), 1000);
3123        let picker = TwcsPicker {
3124            trigger_file_num: 2,
3125            active_window_l1_merge_trigger: 8,
3126            inactive_window_trigger_file_num: 2,
3127            inactive_window_l1_merge_trigger: 8,
3128            time_window_seconds: Some(1000),
3129            max_output_file_size: None,
3130            append_mode: false,
3131            merge_mode: MergeMode::LastRow,
3132            max_background_tasks: None,
3133            time_range: None,
3134            all_windows_inactive: false,
3135        };
3136
3137        let output = picker
3138            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
3139            .await
3140            .unwrap();
3141
3142        assert_eq!(1, output.len());
3143        assert_eq!(DEFAULT_MAX_INPUT_FILES, output[0].inputs.len());
3144        assert!(!output[0].filter_deleted);
3145    }
3146
3147    fn new_file(start: i64, end: i64, sequence: u64, file_size: u64) -> FileHandle {
3148        new_file_handle_with_size_and_sequence(FileId::random(), start, end, 0, sequence, file_size)
3149    }
3150
3151    fn new_file_with_level_and_rows(
3152        start: i64,
3153        end: i64,
3154        level: Level,
3155        sequence: u64,
3156        file_size: u64,
3157        num_rows: u64,
3158    ) -> FileHandle {
3159        let file = new_file_handle_with_size_and_sequence(
3160            FileId::random(),
3161            start,
3162            end,
3163            level,
3164            sequence,
3165            file_size,
3166        );
3167        let mut meta = file.meta_ref().clone();
3168        meta.num_rows = num_rows;
3169        FileHandle::new(meta, crate::test_util::new_noop_file_purger())
3170    }
3171
3172    fn picked_ranges(files: &[FileHandle]) -> Vec<(i64, i64)> {
3173        files
3174            .iter()
3175            .map(|file| {
3176                let (start, end) = file.range();
3177                (start.value(), end.value())
3178            })
3179            .collect()
3180    }
3181
3182    #[test]
3183    fn test_count_first_rejects_dominant_historical_file() {
3184        let picked = pick_count_first(
3185            vec![SortedRun::from(vec![
3186                new_file(0, 9, 1, 400),
3187                new_file(20, 29, 2, 100),
3188            ])],
3189            None,
3190        );
3191
3192        assert!(picked.is_empty());
3193    }
3194
3195    #[test]
3196    fn test_count_first_accepts_balanced_historical_file() {
3197        let picked = pick_count_first(
3198            vec![SortedRun::from(vec![
3199                new_file(0, 9, 1, 400),
3200                new_file(20, 29, 2, 100),
3201                new_file(40, 49, 3, 100),
3202                new_file(60, 69, 4, 100),
3203                new_file(80, 89, 5, 100),
3204            ])],
3205            None,
3206        );
3207
3208        assert_eq!(
3209            vec![(0, 9), (20, 29), (40, 49), (60, 69), (80, 89)],
3210            picked_ranges(&picked)
3211        );
3212    }
3213
3214    #[test]
3215    fn test_count_first_finds_smaller_balanced_interval_when_larger_one_is_unbalanced() {
3216        let picked = pick_count_first(
3217            vec![SortedRun::from(vec![
3218                new_file(0, 9, 1, 1000),
3219                new_file(20, 29, 2, 100),
3220                new_file(40, 49, 3, 100),
3221                new_file(60, 69, 4, 100),
3222                new_file(80, 89, 5, 100),
3223            ])],
3224            None,
3225        );
3226
3227        assert_eq!(
3228            vec![(20, 29), (40, 49), (60, 69), (80, 89)],
3229            picked_ranges(&picked)
3230        );
3231    }
3232
3233    #[test]
3234    fn test_count_first_prefers_overlap_participants_when_file_counts_match() {
3235        let first_run = (0..DEFAULT_MAX_INPUT_FILES)
3236            .map(|idx| {
3237                let start = idx as i64 * 20;
3238                let end = if idx + 1 == DEFAULT_MAX_INPUT_FILES {
3239                    700
3240                } else {
3241                    start + 9
3242                };
3243                new_file(start, end, idx as u64 + 1, 10)
3244            })
3245            .collect::<Vec<_>>();
3246        let overlapping = new_file(690, 710, 100, 10);
3247
3248        let picked = pick_count_first(
3249            vec![
3250                SortedRun::from(first_run),
3251                SortedRun::from(vec![overlapping]),
3252            ],
3253            None,
3254        );
3255        let ranges = picked_ranges(&picked);
3256
3257        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3258        assert!(!ranges.contains(&(0, 9)));
3259        assert!(ranges.contains(&(690, 710)));
3260    }
3261
3262    #[tokio::test]
3263    async fn test_picker_avoids_chained_l1_rewrites_by_compacting_levels_separately() {
3264        let mut enough_l0 = (0..DEFAULT_MAX_INPUT_FILES)
3265            .map(|idx| {
3266                let start = idx as i64 * 20;
3267                new_file_handle_with_size_and_sequence(
3268                    FileId::random(),
3269                    start,
3270                    start + 9,
3271                    0,
3272                    idx as u64 + 1,
3273                    10,
3274                )
3275            })
3276            .collect::<Vec<_>>();
3277        enough_l0.push(new_file_handle_with_size_and_sequence(
3278            FileId::random(),
3279            0,
3280            700,
3281            1,
3282            100,
3283            100,
3284        ));
3285        let mut enough_l1 = (0..4)
3286            .map(|idx| {
3287                new_file_handle_with_size_and_sequence(
3288                    FileId::random(),
3289                    idx * 100,
3290                    idx * 100 + 99,
3291                    1,
3292                    idx as u64 + 1,
3293                    100,
3294                )
3295            })
3296            .collect::<Vec<_>>();
3297        enough_l1.extend((0..3).map(|idx| {
3298            new_file_handle_with_size_and_sequence(
3299                FileId::random(),
3300                idx * 20,
3301                idx * 20 + 9,
3302                0,
3303                idx as u64 + 10,
3304                10,
3305            )
3306        }));
3307        let picker = TwcsPicker {
3308            trigger_file_num: 4,
3309            active_window_l1_merge_trigger: 8,
3310            inactive_window_trigger_file_num: 4,
3311            inactive_window_l1_merge_trigger: 4,
3312            time_window_seconds: Some(1),
3313            max_output_file_size: None,
3314            append_mode: false,
3315            merge_mode: MergeMode::LastRow,
3316            max_background_tasks: None,
3317            time_range: None,
3318            all_windows_inactive: false,
3319        };
3320
3321        for (case, files, expected_level, expected_len) in [
3322            ("L0 reaches trigger", enough_l0, 0, DEFAULT_MAX_INPUT_FILES),
3323            ("L0 fallback precedes triggered L1", enough_l1, 0, 3),
3324        ] {
3325            let windows = assign_to_windows(files.iter(), 1);
3326            let output = picker
3327                .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3328                .await
3329                .unwrap();
3330
3331            assert_eq!(1, output.len(), "{case}");
3332            assert_eq!(expected_len, output[0].inputs.len(), "{case}");
3333            assert!(
3334                output[0]
3335                    .inputs
3336                    .iter()
3337                    .all(|file| file.level() == expected_level),
3338                "{case}"
3339            );
3340        }
3341    }
3342
3343    #[tokio::test]
3344    async fn test_active_window_defers_l1_below_safety_trigger() {
3345        let files = (0..7)
3346            .map(|idx| {
3347                let start = idx * 100;
3348                new_file_handle_with_size_and_sequence(
3349                    FileId::random(),
3350                    start,
3351                    start + 99,
3352                    1,
3353                    idx as u64 + 1,
3354                    100,
3355                )
3356            })
3357            .collect::<Vec<_>>();
3358        let windows = assign_to_windows(files.iter(), 1);
3359        let active_window = windows.keys().next().copied();
3360        let picker = TwcsPicker {
3361            trigger_file_num: 4,
3362            active_window_l1_merge_trigger: 8,
3363            inactive_window_trigger_file_num: 2,
3364            inactive_window_l1_merge_trigger: 8,
3365            time_window_seconds: Some(1),
3366            max_output_file_size: None,
3367            append_mode: false,
3368            merge_mode: MergeMode::LastRow,
3369            max_background_tasks: None,
3370            time_range: None,
3371            all_windows_inactive: false,
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!(output.is_empty());
3380    }
3381
3382    #[tokio::test]
3383    async fn test_active_window_l1_safety_trigger_is_independent_of_l0_trigger() {
3384        let files = (0..8)
3385            .map(|idx| {
3386                let start = idx * 100;
3387                new_file_handle_with_size_and_sequence(
3388                    FileId::random(),
3389                    start,
3390                    start + 99,
3391                    1,
3392                    idx as u64 + 1,
3393                    100,
3394                )
3395            })
3396            .collect::<Vec<_>>();
3397        let windows = assign_to_windows(files.iter(), 1);
3398        let active_window = windows.keys().next().copied();
3399        let picker = TwcsPicker {
3400            trigger_file_num: 16,
3401            active_window_l1_merge_trigger: 8,
3402            inactive_window_trigger_file_num: 2,
3403            inactive_window_l1_merge_trigger: 8,
3404            time_window_seconds: Some(1),
3405            max_output_file_size: None,
3406            append_mode: false,
3407            merge_mode: MergeMode::LastRow,
3408            max_background_tasks: None,
3409            time_range: None,
3410            all_windows_inactive: false,
3411        };
3412
3413        let output = picker
3414            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3415            .await
3416            .unwrap();
3417
3418        assert_eq!(1, output.len());
3419        assert_eq!(8, output[0].inputs.len());
3420        assert!(output[0].inputs.iter().all(|file| file.level() == 1));
3421    }
3422
3423    #[tokio::test]
3424    async fn test_picker_falls_back_to_l1_when_triggered_l0_cannot_make_progress() {
3425        let mut files = (0..4)
3426            .map(|idx| {
3427                let start = idx * 20;
3428                new_file_handle_with_size_and_sequence(
3429                    FileId::random(),
3430                    start,
3431                    start + 9,
3432                    0,
3433                    idx as u64 + 1,
3434                    600,
3435                )
3436            })
3437            .collect::<Vec<_>>();
3438        files.extend((0..8).map(|idx| {
3439            let start = idx * 20 + 100;
3440            new_file_handle_with_size_and_sequence(
3441                FileId::random(),
3442                start,
3443                start + 9,
3444                1,
3445                idx as u64 + 10,
3446                50,
3447            )
3448        }));
3449        let windows = assign_to_windows(files.iter(), 1);
3450        let active_window = windows.keys().next().copied();
3451        let picker = TwcsPicker {
3452            trigger_file_num: 4,
3453            active_window_l1_merge_trigger: 8,
3454            inactive_window_trigger_file_num: 4,
3455            inactive_window_l1_merge_trigger: 8,
3456            time_window_seconds: Some(1),
3457            max_output_file_size: Some(512),
3458            append_mode: false,
3459            merge_mode: MergeMode::LastRow,
3460            max_background_tasks: None,
3461            time_range: None,
3462            all_windows_inactive: false,
3463        };
3464
3465        let output = picker
3466            .build_output_with_time_range(RegionId::from_u64(1), windows, active_window, None)
3467            .await
3468            .unwrap();
3469
3470        assert_eq!(1, output.len());
3471        assert_eq!(8, output[0].inputs.len());
3472        assert!(output[0].inputs.iter().all(|file| file.level() == 1));
3473    }
3474
3475    fn priority_test_files(
3476        window: i64,
3477        level: Level,
3478        file_size: u64,
3479        overlap: bool,
3480    ) -> [FileHandle; 2] {
3481        let start = window * 1_000;
3482        let ranges = if overlap {
3483            [(start, start + 499), (start + 250, start + 749)]
3484        } else {
3485            [(start, start + 99), (start + 200, start + 299)]
3486        };
3487        ranges.map(|(start, end)| {
3488            new_file_handle_with_size_and_sequence(
3489                FileId::random(),
3490                start,
3491                end,
3492                level,
3493                window as u64 + 1,
3494                file_size,
3495            )
3496        })
3497    }
3498
3499    fn priority_test_picker(max_background_tasks: usize) -> TwcsPicker {
3500        TwcsPicker {
3501            trigger_file_num: 2,
3502            active_window_l1_merge_trigger: 2,
3503            inactive_window_trigger_file_num: 2,
3504            inactive_window_l1_merge_trigger: 2,
3505            time_window_seconds: Some(1),
3506            max_output_file_size: Some(100),
3507            append_mode: false,
3508            merge_mode: MergeMode::LastRow,
3509            max_background_tasks: Some(max_background_tasks),
3510            time_range: None,
3511            all_windows_inactive: false,
3512        }
3513    }
3514
3515    fn output_window(output: &CompactionOutput) -> i64 {
3516        output.inputs[0]
3517            .time_range()
3518            .1
3519            .convert_to(TimeUnit::Second)
3520            .unwrap()
3521            .value()
3522    }
3523
3524    #[tokio::test]
3525    async fn test_manual_compaction_resolves_active_window_overlaps() {
3526        let files = priority_test_files(5, 1, 100, true);
3527        for (all_windows_inactive, expected) in [(false, 0), (true, 1)] {
3528            let picker = TwcsPicker {
3529                all_windows_inactive,
3530                ..priority_test_picker(1)
3531            };
3532            let output = picker
3533                .build_output_with_time_range(
3534                    RegionId::from_u64(1),
3535                    assign_to_windows(files.iter(), 1),
3536                    Some(5),
3537                    None,
3538                )
3539                .await
3540                .unwrap();
3541            assert_eq!(expected, output.len(), "manual: {all_windows_inactive}");
3542        }
3543    }
3544
3545    #[tokio::test]
3546    async fn test_shared_boundary_l1_files_resolve_in_inactive_and_manual_windows() {
3547        let files = [(b"a", b"m"), (b"m", b"z")].map(|(min, max)| {
3548            new_file_handle_with_size_sequence_and_primary_key_range(
3549                FileId::random(),
3550                0,
3551                100,
3552                1,
3553                1,
3554                100,
3555                pk_range(min, max),
3556            )
3557        });
3558        for (active_window, all_windows_inactive, expected) in
3559            [(0, false, 0), (0, true, 1), (3, false, 1)]
3560        {
3561            let picker = TwcsPicker {
3562                all_windows_inactive,
3563                ..priority_test_picker(1)
3564            };
3565            let output = picker
3566                .build_output_with_time_range(
3567                    RegionId::from_u64(1),
3568                    assign_to_windows(files.iter(), 1),
3569                    Some(active_window),
3570                    Some(1),
3571                )
3572                .await
3573                .unwrap();
3574            assert_eq!(
3575                expected,
3576                output.len(),
3577                "active: {active_window}, manual: {all_windows_inactive}"
3578            );
3579            if let Some(output) = output.first() {
3580                assert_eq!(2, output.inputs.len());
3581            }
3582        }
3583    }
3584
3585    #[tokio::test]
3586    async fn test_settling_window_defers_overlap_resolution() {
3587        let files = priority_test_files(3, 1, 100, true);
3588        for (active_window, all_windows_inactive, expected) in
3589            [(4, false, 0), (5, false, 1), (2, false, 1), (4, true, 1)]
3590        {
3591            let picker = TwcsPicker {
3592                all_windows_inactive,
3593                ..priority_test_picker(1)
3594            };
3595            let output = picker
3596                .build_output_with_time_range(
3597                    RegionId::from_u64(1),
3598                    assign_to_windows(files.iter(), 1),
3599                    Some(active_window),
3600                    Some(1),
3601                )
3602                .await
3603                .unwrap();
3604            assert_eq!(
3605                expected,
3606                output.len(),
3607                "active: {active_window}, manual: {all_windows_inactive}"
3608            );
3609        }
3610    }
3611
3612    #[tokio::test]
3613    async fn test_inactive_windows_merge_small_l1_without_large_peer() {
3614        let large =
3615            new_file_handle_with_size_and_sequence(FileId::random(), 3_000, 3_999, 1, 1, 1_000);
3616        let small = priority_test_files(3, 1, 20, true);
3617        let files = [vec![large.clone()], small.to_vec()].concat();
3618        // The immediate predecessor is settling; the older window is not quiet.
3619        for (active_window, merge_mode, trigger, expected) in [
3620            (4, MergeMode::LastRow, 2, 1),
3621            (5, MergeMode::LastRow, 2, 1),
3622            (4, MergeMode::LastNonNull, 2, 0),
3623            (5, MergeMode::LastNonNull, 2, 0),
3624            (4, MergeMode::LastRow, 4, 0),
3625        ] {
3626            let picker = TwcsPicker {
3627                merge_mode,
3628                inactive_window_l1_merge_trigger: trigger,
3629                ..priority_test_picker(1)
3630            };
3631            let output = picker
3632                .build_output_with_time_range(
3633                    RegionId::from_u64(1),
3634                    assign_to_windows(files.iter(), 1),
3635                    Some(active_window),
3636                    Some(1),
3637                )
3638                .await
3639                .unwrap();
3640            assert_eq!(
3641                expected,
3642                output.len(),
3643                "active: {active_window}, mode: {merge_mode:?}, trigger: {trigger}"
3644            );
3645            if expected == 0 {
3646                continue;
3647            }
3648            let inputs: HashSet<_> = output[0].inputs.iter().map(FileHandle::file_id).collect();
3649            assert_eq!(
3650                small
3651                    .iter()
3652                    .map(FileHandle::file_id)
3653                    .collect::<HashSet<_>>(),
3654                inputs
3655            );
3656            assert!(
3657                !output[0].filter_deleted,
3658                "the unselected large file still overlaps"
3659            );
3660        }
3661    }
3662
3663    #[tokio::test]
3664    async fn test_small_l1_fallback_checks_large_append_mode_peer() {
3665        // Two conflicting small files form a non-rectangular area. Their union
3666        // would introduce a conflict with the large file in the empty corner.
3667        let files = [
3668            (0, 20, b"a", b"m", 20),
3669            (10, 30, b"l", b"z", 20),
3670            (0, 5, b"y", b"z", 1_000),
3671        ]
3672        .map(|(start, end, min, max, size)| {
3673            new_file_handle_with_size_sequence_and_primary_key_range(
3674                FileId::random(),
3675                start,
3676                end,
3677                1,
3678                1,
3679                size,
3680                pk_range(min, max),
3681            )
3682        });
3683        for append_mode in [false, true] {
3684            let picker = TwcsPicker {
3685                append_mode,
3686                ..priority_test_picker(1)
3687            };
3688            let output = picker
3689                .build_output_with_time_range(
3690                    RegionId::from_u64(1),
3691                    assign_to_windows(files.iter(), 1),
3692                    Some(1),
3693                    Some(1),
3694                )
3695                .await
3696                .unwrap();
3697            assert!(output.is_empty(), "append mode: {append_mode}");
3698        }
3699    }
3700
3701    #[tokio::test]
3702    async fn test_settling_window_does_not_rewrite_l1_for_late_data() {
3703        // A late L0 file overlapping both L1 files, which a mixed merge absorbs.
3704        let mut files = priority_test_files(3, 1, 40, false).to_vec();
3705        files.push(new_file_handle_with_size_and_sequence(
3706            FileId::random(),
3707            3_000,
3708            3_299,
3709            0,
3710            10,
3711            10,
3712        ));
3713        for (active_window, expected) in [(4, 0), (5, 1)] {
3714            let output = priority_test_picker(1)
3715                .build_output_with_time_range(
3716                    RegionId::from_u64(1),
3717                    assign_to_windows(files.iter(), 1),
3718                    Some(active_window),
3719                    Some(1),
3720                )
3721                .await
3722                .unwrap();
3723            assert_eq!(expected, output.len(), "active: {active_window}");
3724        }
3725    }
3726
3727    #[tokio::test]
3728    async fn test_older_l0_candidate_has_priority_over_newer_pure_l1_candidate() {
3729        let files = priority_test_files(0, 0, 40, false)
3730            .into_iter()
3731            .chain(priority_test_files(1, 1, 40, false))
3732            .collect::<Vec<_>>();
3733        let windows = assign_to_windows(files.iter(), 1);
3734
3735        let output = priority_test_picker(1)
3736            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3737            .await
3738            .unwrap();
3739
3740        assert_eq!(1, output.len());
3741        assert_eq!(0, output_window(&output[0]));
3742        assert!(output[0].inputs.iter().all(|file| file.level() == 0));
3743    }
3744
3745    #[tokio::test]
3746    async fn test_older_l1_file_reduction_has_priority_over_newer_overlap_resolution() {
3747        let files = priority_test_files(0, 1, 40, false)
3748            .into_iter()
3749            .chain(priority_test_files(1, 1, 100, true))
3750            .collect::<Vec<_>>();
3751        let windows = assign_to_windows(files.iter(), 1);
3752
3753        let output = priority_test_picker(1)
3754            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(1), None)
3755            .await
3756            .unwrap();
3757
3758        assert_eq!(1, output.len());
3759        assert_eq!(0, output_window(&output[0]));
3760    }
3761
3762    #[tokio::test]
3763    async fn test_multiple_slots_follow_class_priority_and_newer_first_within_class() {
3764        let files = [
3765            priority_test_files(0, 0, 40, false),
3766            priority_test_files(1, 0, 40, false),
3767            priority_test_files(2, 1, 40, false),
3768            priority_test_files(3, 1, 40, false),
3769            priority_test_files(4, 1, 100, true),
3770            priority_test_files(5, 1, 100, true),
3771        ]
3772        .into_iter()
3773        .flatten()
3774        .collect::<Vec<_>>();
3775        let windows = assign_to_windows(files.iter(), 1);
3776
3777        let output = priority_test_picker(5)
3778            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(5), None)
3779            .await
3780            .unwrap();
3781        let pop_priority = output
3782            .iter()
3783            .rev()
3784            .map(|output| (output.inputs[0].level(), output_window(output)))
3785            .collect::<Vec<_>>();
3786
3787        // The active window 5 only reduces files, so the overlap slot goes to
3788        // the inactive window 4.
3789        assert_eq!(vec![(0, 1), (0, 0), (1, 3), (1, 2), (1, 4)], pop_priority);
3790        let units = crate::compaction::unit::CompactionUnit::from_picker(PickerOutput {
3791            outputs: output,
3792            time_window_size: 1,
3793            ..Default::default()
3794        })
3795        .unwrap();
3796        let unit_priority = units
3797            .iter()
3798            .map(|unit| {
3799                (
3800                    unit.outputs[0].inputs[0].level(),
3801                    output_window(&unit.outputs[0]),
3802                )
3803            })
3804            .collect::<Vec<_>>();
3805        assert_eq!(
3806            pop_priority, unit_priority,
3807            "local FIFO must follow the picker's execution priority"
3808        );
3809    }
3810
3811    #[tokio::test]
3812    async fn test_inactive_window_mixed_fallback_merges_within_rewrite_budget() {
3813        let files = [
3814            new_file_with_level_and_rows(0, 99, 1, 1, 1_000, 1_000_000),
3815            new_file_with_level_and_rows(0, 9, 0, 2, 10, 1),
3816        ];
3817        let windows = assign_to_windows(files.iter(), 100);
3818        let picker = TwcsPicker {
3819            trigger_file_num: 4,
3820            active_window_l1_merge_trigger: 8,
3821            inactive_window_trigger_file_num: 2,
3822            inactive_window_l1_merge_trigger: 8,
3823            time_window_seconds: Some(100),
3824            max_output_file_size: Some(2_000),
3825            append_mode: false,
3826            merge_mode: MergeMode::LastRow,
3827            max_background_tasks: None,
3828            time_range: None,
3829            all_windows_inactive: false,
3830        };
3831
3832        let output = picker
3833            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3834            .await
3835            .unwrap();
3836
3837        assert_eq!(1, output.len());
3838        assert_eq!(2, output[0].inputs.len());
3839    }
3840
3841    #[tokio::test]
3842    async fn test_inactive_window_over_budget_merges_l0_only() {
3843        // L0 files are unbalanced among themselves, and merging in the huge L1 file
3844        // would exceed the rewrite budget: only the L0 files are compacted.
3845        let files = [
3846            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10_000),
3847            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 10),
3848            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 3, 1_000_000),
3849        ];
3850        let windows = assign_to_windows(files.iter(), 100);
3851        let picker = TwcsPicker {
3852            trigger_file_num: 4,
3853            active_window_l1_merge_trigger: 8,
3854            inactive_window_trigger_file_num: 2,
3855            inactive_window_l1_merge_trigger: 8,
3856            time_window_seconds: Some(100),
3857            max_output_file_size: Some(100_000),
3858            append_mode: false,
3859            merge_mode: MergeMode::LastRow,
3860            max_background_tasks: None,
3861            time_range: None,
3862            all_windows_inactive: false,
3863        };
3864
3865        let output = picker
3866            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3867            .await
3868            .unwrap();
3869
3870        assert_eq!(1, output.len());
3871        assert_eq!(2, output[0].inputs.len());
3872        assert!(output[0].inputs.iter().all(|file| file.level() == 0));
3873    }
3874
3875    #[tokio::test]
3876    async fn test_inactive_window_over_budget_without_l0_pair_does_not_merge() {
3877        // A single tiny L0 and a huge L1: merging is over budget and there is no
3878        // L0 pair to converge, so the window is left as-is.
3879        let files = [
3880            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10),
3881            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 2, 1_000_000),
3882        ];
3883        let windows = assign_to_windows(files.iter(), 100);
3884        let picker = TwcsPicker {
3885            trigger_file_num: 4,
3886            active_window_l1_merge_trigger: 8,
3887            inactive_window_trigger_file_num: 2,
3888            inactive_window_l1_merge_trigger: 8,
3889            time_window_seconds: Some(100),
3890            max_output_file_size: Some(100_000),
3891            append_mode: false,
3892            merge_mode: MergeMode::LastRow,
3893            max_background_tasks: None,
3894            time_range: None,
3895            all_windows_inactive: false,
3896        };
3897
3898        let output = picker
3899            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3900            .await
3901            .unwrap();
3902
3903        assert!(output.is_empty());
3904    }
3905
3906    #[tokio::test]
3907    async fn test_inactive_window_falls_through_when_triggered_l0_pick_fails() {
3908        // L0 reaches the inactive trigger but is unbalanced; L1 is below the
3909        // trigger. The window must still converge through the budgeted mixed
3910        // fallback instead of being skipped.
3911        let files = [
3912            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 100_000),
3913            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 2, 10),
3914            new_file_handle_with_size_and_sequence(FileId::random(), 0, 99, 1, 3, 50_000),
3915        ];
3916        let windows = assign_to_windows(files.iter(), 100);
3917        let picker = TwcsPicker {
3918            trigger_file_num: 4,
3919            active_window_l1_merge_trigger: 8,
3920            inactive_window_trigger_file_num: 2,
3921            inactive_window_l1_merge_trigger: 8,
3922            time_window_seconds: Some(100),
3923            max_output_file_size: Some(1_000_000),
3924            append_mode: false,
3925            merge_mode: MergeMode::LastRow,
3926            max_background_tasks: None,
3927            time_range: None,
3928            all_windows_inactive: false,
3929        };
3930
3931        let output = picker
3932            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(100), None)
3933            .await
3934            .unwrap();
3935
3936        assert_eq!(1, output.len());
3937        assert_eq!(3, output[0].inputs.len());
3938    }
3939
3940    #[test]
3941    fn test_mixed_candidate_row_balance() {
3942        for (case, l1_rows, l0_rows, expected_len) in [
3943            ("L1 rows dominate", 1_000_000, 10_000, 0),
3944            ("L1 rows meet ratio", 60_000, 10_000, 4),
3945            ("L0 rows are unknown", 1_000_000, 0, 4),
3946        ] {
3947            let files = vec![
3948                new_file_with_level_and_rows(0, 99, 1, 1, 100, l1_rows),
3949                new_file_with_level_and_rows(0, 9, 0, 2, 100, l0_rows),
3950                new_file_with_level_and_rows(20, 29, 0, 3, 100, l0_rows),
3951                new_file_with_level_and_rows(40, 49, 0, 4, 100, l0_rows),
3952            ];
3953
3954            let picked = pick_mixed_count_first(vec![SortedRun::from(files)], None);
3955
3956            assert_eq!(expected_len, picked.len(), "{case}");
3957        }
3958    }
3959
3960    #[test]
3961    fn test_count_first_prefers_smaller_bytes_when_file_counts_match() {
3962        let files = (0..=DEFAULT_MAX_INPUT_FILES)
3963            .map(|idx| {
3964                let start = idx as i64 * 20;
3965                let size = if idx == 0 {
3966                    100
3967                } else if idx == DEFAULT_MAX_INPUT_FILES {
3968                    1
3969                } else {
3970                    10
3971                };
3972                new_file(start, start + 9, idx as u64 + 1, size)
3973            })
3974            .collect::<Vec<_>>();
3975
3976        let picked = pick_count_first(vec![SortedRun::from(files)], None);
3977        let ranges = picked_ranges(&picked);
3978
3979        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
3980        assert_eq!(Some(&(20, 29)), ranges.first());
3981        let last_start = DEFAULT_MAX_INPUT_FILES as i64 * 20;
3982        assert_eq!(Some(&(last_start, last_start + 9)), ranges.last());
3983    }
3984
3985    #[test]
3986    fn test_count_first_skips_pure_rewrite_without_progress() {
3987        // Four non-overlapping files whose combined size splits back into at least
3988        // four outputs: compacting them reduces neither the file count nor any
3989        // overlap, so there is nothing worth picking.
3990        let picked = pick_count_first(
3991            vec![SortedRun::from(vec![
3992                new_file(0, 9, 1, 600),
3993                new_file(20, 29, 2, 600),
3994                new_file(40, 49, 3, 600),
3995                new_file(60, 69, 4, 600),
3996            ])],
3997            Some(512),
3998        );
3999
4000        assert!(picked.is_empty());
4001    }
4002
4003    #[test]
4004    fn test_count_first_allows_overlap_resolution_without_file_reduction() {
4005        // Two large overlapping files from different runs: compacting them cannot
4006        // reduce the file count (the output splits back into just as many files),
4007        // but it resolves the overlap and merges two sorted runs into one.
4008        let picked = pick_count_first(
4009            vec![
4010                SortedRun::from(vec![new_file(0, 19, 1, 600)]),
4011                SortedRun::from(vec![new_file(10, 29, 2, 600)]),
4012            ],
4013            Some(512),
4014        );
4015
4016        assert_eq!(vec![(0, 19), (10, 29)], picked_ranges(&picked));
4017    }
4018
4019    #[tokio::test]
4020    async fn test_l1_partial_pk_overlap_does_not_rewrite_unrelated_files() {
4021        // Region 1025's 2026-09-23 R2: A1..A8 are R1's outputs, plus one L1 E.
4022        // Keep the logged, rounded sizes (tenths of MiB) and normalize timestamps
4023        // while preserving the observed order: A3, A7, A6, A1, A4, A5, A2, A8, E.
4024        // The PK ranges are hypothetical: the logs only contain their lengths.
4025        let a_files = [
4026            (4, 5249), // A1
4027            (7, 5218), // A2
4028            (1, 5255), // A3
4029            (5, 5263), // A4
4030            (6, 5131), // A5
4031            (3, 5143), // A6
4032            (2, 5197), // A7
4033            (8, 4716), // A8
4034        ]
4035        .into_iter()
4036        .enumerate()
4037        .map(|(idx, (start, size))| {
4038            let key = b'a' + idx as u8;
4039            new_file_handle_with_size_sequence_and_primary_key_range(
4040                FileId::random(),
4041                start,
4042                10_000,
4043                1,
4044                100,
4045                size * 1024 * 1024 / 10,
4046                pk_range(&[key, b'0'], &[key, b'9']),
4047            )
4048        })
4049        .collect::<Vec<_>>();
4050        let mapper = primary_key_mapper_for_test();
4051        for (idx, file) in a_files.iter().enumerate() {
4052            for other in &a_files[idx + 1..] {
4053                assert!(file.overlap_inclusive(other)); // Their time ranges overlap.
4054                assert!(!files_overlap_inclusive(file, other, &mapper));
4055            }
4056        }
4057
4058        let picker = TwcsPicker {
4059            trigger_file_num: 4,
4060            active_window_l1_merge_trigger: 4,
4061            inactive_window_trigger_file_num: 2,
4062            inactive_window_l1_merge_trigger: 8,
4063            time_window_seconds: Some(60),
4064            max_output_file_size: Some(512 * 1024 * 1024),
4065            append_mode: false,
4066            merge_mode: MergeMode::LastRow,
4067            max_background_tasks: Some(1),
4068            time_range: None,
4069            all_windows_inactive: false,
4070        };
4071
4072        for (case, key_range, extra_size, overlapping_a, expected_inputs) in [
4073            (
4074                "A3 and E skip the unrelated A files",
4075                Some((b'c', b'c')),
4076                5155,
4077                &[2][..],
4078                &[2, 8][..],
4079            ),
4080            (
4081                "A8 and E must not pull in A2",
4082                Some((b'h', b'h')),
4083                5155,
4084                &[7][..],
4085                &[7, 8][..],
4086            ),
4087            (
4088                "E overlaps no A: no progress",
4089                Some((b'z', b'z')),
4090                5155,
4091                &[][..],
4092                &[] as &[usize],
4093            ),
4094            (
4095                "A3 and E may split into one extra tail file",
4096                Some((b'c', b'c')),
4097                5255,
4098                &[2][..],
4099                &[2, 8][..],
4100            ),
4101            (
4102                "two A files exceed E's run budget",
4103                Some((b'b', b'c')),
4104                5155,
4105                &[1, 2][..],
4106                &[][..],
4107            ),
4108            (
4109                "sufficient E bytes allow two A files separated in time order",
4110                Some((b'b', b'c')),
4111                6000,
4112                &[1, 2][..],
4113                &[1, 2, 8][..],
4114            ),
4115            (
4116                "unknown PK bounds include the whole dominant run in the budget",
4117                None,
4118                5155,
4119                &[0, 1, 2, 3, 4, 5, 6, 7][..],
4120                &[][..],
4121            ),
4122        ] {
4123            let extra = new_file_handle_with_size_sequence_and_primary_key_range(
4124                FileId::random(),
4125                1_000,
4126                9_000,
4127                1,
4128                100,
4129                extra_size * 1024 * 1024 / 10,
4130                key_range.and_then(|(min, max)| pk_range(&[min, b'0'], &[max, b'9'])),
4131            );
4132            for (idx, file) in a_files.iter().enumerate() {
4133                assert!(file.overlap_inclusive(&extra));
4134                assert_eq!(
4135                    overlapping_a.contains(&idx),
4136                    files_overlap_inclusive(file, &extra, &mapper),
4137                    "{case}: A{}",
4138                    idx + 1,
4139                );
4140            }
4141
4142            let mut files = a_files.clone();
4143            files.push(extra);
4144            for active_window in [Some(0), None] {
4145                // The active window leaves pure overlap resolution to the window
4146                // becoming inactive.
4147                let expected_inputs = if active_window.is_some() {
4148                    &[][..]
4149                } else {
4150                    expected_inputs
4151                };
4152                let expected = expected_inputs
4153                    .iter()
4154                    .map(|&idx| files[idx].file_id())
4155                    .collect::<HashSet<_>>();
4156                let windows = assign_to_windows(files.iter(), 60);
4157                let outputs = picker
4158                    .build_output_with_time_range(
4159                        RegionId::from_u64(0),
4160                        windows,
4161                        active_window,
4162                        Some(60),
4163                    )
4164                    .await
4165                    .unwrap();
4166
4167                assert_eq!(
4168                    usize::from(!expected_inputs.is_empty()),
4169                    outputs.len(),
4170                    "{case}, active window: {active_window:?}"
4171                );
4172                let actual = outputs
4173                    .iter()
4174                    .flat_map(|output| output.inputs.iter().map(FileHandle::file_id))
4175                    .collect::<HashSet<_>>();
4176                assert_eq!(expected, actual, "{case}, active window: {active_window:?}");
4177            }
4178        }
4179    }
4180
4181    #[rstest::rstest]
4182    #[case::active_filtered(Some(0), false, 1000, &[0, 2])]
4183    #[case::inactive_filtered(None, false, 1000, &[0, 2])]
4184    #[case::older_reduction_before_newer_overlap(Some(3), true, 1000, &[0, 2])]
4185    #[case::contiguous_has_more_reduction(Some(0), false, 90, &[0, 1, 2, 3])]
4186    #[case::filtered_has_equal_reduction_with_less_io(Some(0), false, 600, &[0, 2])]
4187    #[tokio::test]
4188    async fn test_l1_file_reduction_considers_filtered_candidates(
4189        #[case] active_window: Option<i64>,
4190        #[case] newer_overlap_window: bool,
4191        #[case] unrelated_size: u64,
4192        #[case] expected_indices: &[usize],
4193    ) {
4194        // Only A/B overlap. With 1000-byte unrelated files between/after them,
4195        // no contiguous interval reduces the file count, but A+B alone does.
4196        let mut files = [
4197            (b'a', 100),
4198            (b'b', unrelated_size),
4199            (b'a', 100),
4200            (b'c', unrelated_size),
4201        ]
4202        .into_iter()
4203        .enumerate()
4204        .map(|(idx, (key, size))| {
4205            new_file_handle_with_size_sequence_and_primary_key_range(
4206                FileId::random(),
4207                idx as i64 * 100,
4208                749,
4209                1,
4210                1,
4211                size,
4212                pk_range(&[key], &[key]),
4213            )
4214        })
4215        .collect::<Vec<_>>();
4216        let expected = expected_indices
4217            .iter()
4218            .map(|&idx| files[idx].file_id())
4219            .collect::<Vec<_>>();
4220        if newer_overlap_window {
4221            // One slot: a filtered file-reducing pick must beat a newer pure
4222            // rewrite, rather than being deferred behind overlap resolution.
4223            files.extend(priority_test_files(1, 1, 512, true));
4224        }
4225        let picker = TwcsPicker {
4226            max_output_file_size: Some(512),
4227            ..priority_test_picker(1)
4228        };
4229        let outputs = picker
4230            .build_output_with_time_range(
4231                RegionId::from_u64(0),
4232                assign_to_windows(files.iter(), 1),
4233                active_window,
4234                Some(1),
4235            )
4236            .await
4237            .unwrap();
4238
4239        assert_eq!(1, outputs.len());
4240        assert_eq!(
4241            expected,
4242            outputs[0]
4243                .inputs
4244                .iter()
4245                .map(FileHandle::file_id)
4246                .collect::<Vec<_>>()
4247        );
4248    }
4249
4250    #[tokio::test]
4251    async fn test_overlap_resolution_limits_dominant_run_rewrites() {
4252        let file_size = 512 * 1024 * 1024;
4253        // Peers are full-size files in one run, so a pure rewrite keeps the file count.
4254        for (case, peer_sizes, max_output_size, should_pick) in [
4255            (
4256                "small peer cannot rewrite any slice of the large run",
4257                vec![file_size],
4258                file_size,
4259                false,
4260            ),
4261            (
4262                "one byte below the run budget",
4263                [vec![file_size; 6], vec![file_size - 1]].concat(),
4264                file_size,
4265                false,
4266            ),
4267            (
4268                "exact run budget boundary",
4269                vec![file_size; 7],
4270                file_size,
4271                true,
4272            ),
4273            ("equal sized runs", vec![file_size; 8], file_size, true),
4274            (
4275                "file reduction keeps its own admission",
4276                vec![file_size],
4277                2 * file_size,
4278                true,
4279            ),
4280        ] {
4281            let mut files = (0..8)
4282                .map(|idx| {
4283                    let key = b'a' + idx as u8;
4284                    new_file_handle_with_size_sequence_and_primary_key_range(
4285                        FileId::random(),
4286                        idx,
4287                        10_000,
4288                        1,
4289                        100,
4290                        file_size,
4291                        pk_range(&[key, b'0'], &[key, b'9']),
4292                    )
4293                })
4294                .collect::<Vec<_>>();
4295            files.extend(peer_sizes.into_iter().enumerate().map(|(idx, size)| {
4296                let start = 1_000 * (idx as i64 + 1);
4297                new_file_handle_with_size_sequence_and_primary_key_range(
4298                    FileId::random(),
4299                    start,
4300                    start + 999,
4301                    1,
4302                    100,
4303                    size,
4304                    pk_range(b"a0", b"h9"),
4305                )
4306            }));
4307            // Only the last case removes files; the active window leaves pure
4308            // overlap resolution to the window becoming inactive.
4309            let reduces_files = max_output_size > file_size;
4310            let picker = TwcsPicker {
4311                trigger_file_num: 4,
4312                active_window_l1_merge_trigger: 4,
4313                inactive_window_trigger_file_num: 2,
4314                inactive_window_l1_merge_trigger: 8,
4315                time_window_seconds: Some(60),
4316                max_output_file_size: Some(max_output_size),
4317                append_mode: false,
4318                merge_mode: MergeMode::LastRow,
4319                max_background_tasks: Some(1),
4320                time_range: None,
4321                all_windows_inactive: false,
4322            };
4323
4324            for active_window in [Some(0), None] {
4325                let expected = if should_pick && (reduces_files || active_window.is_none()) {
4326                    files
4327                        .iter()
4328                        .map(FileHandle::file_id)
4329                        .collect::<HashSet<_>>()
4330                } else {
4331                    HashSet::new()
4332                };
4333                let outputs = picker
4334                    .build_output_with_time_range(
4335                        RegionId::from_u64(0),
4336                        assign_to_windows(files.iter(), 60),
4337                        active_window,
4338                        Some(60),
4339                    )
4340                    .await
4341                    .unwrap();
4342                let actual = outputs
4343                    .iter()
4344                    .flat_map(|output| output.inputs.iter().map(FileHandle::file_id))
4345                    .collect::<HashSet<_>>();
4346                assert_eq!(expected, actual, "{case}, active window: {active_window:?}");
4347            }
4348        }
4349    }
4350
4351    #[tokio::test]
4352    async fn test_quiet_or_manual_window_resolves_dominant_run_overlap() {
4353        let file_size = 512 * 1024 * 1024;
4354        let l1_file =
4355            |start, end, sequence, size, range: Option<(bytes::Bytes, bytes::Bytes)>, num_rows| {
4356                let file = new_file_handle_with_size_sequence_and_primary_key_range(
4357                    FileId::random(),
4358                    start,
4359                    end,
4360                    1,
4361                    sequence,
4362                    size,
4363                    range.clone(),
4364                );
4365                FileHandle::new_with_primary_key_range(
4366                    FileMeta {
4367                        num_rows,
4368                        ..file.meta_ref().clone()
4369                    },
4370                    crate::test_util::new_noop_file_purger(),
4371                    range,
4372                )
4373            };
4374
4375        // The window holds 9 rows. It is quiet once the region writes 9 more
4376        // rows after it, and only if its row counts are known. A manual
4377        // compaction does not wait, even for the active window.
4378        for (newer_sequence, num_rows, manual, resolves) in [
4379            (108, 1, false, false),
4380            (109, 1, false, true),
4381            (109, 0, false, false),
4382            (100, 1, true, true),
4383        ] {
4384            // A run of eight key-sliced files and one small file overlapping all
4385            // of them: resolving the overlap rewrites the run for its small peer.
4386            let mut window_files = (0..8)
4387                .map(|idx| {
4388                    let key = b'a' + idx as u8;
4389                    let range = pk_range(&[key, b'0'], &[key, b'9']);
4390                    l1_file(idx, 10_000, 100, file_size, range, num_rows)
4391                })
4392                .collect::<Vec<_>>();
4393            window_files.push(l1_file(
4394                1_000,
4395                1_999,
4396                100,
4397                file_size / 8,
4398                pk_range(b"a0", b"h9"),
4399                num_rows,
4400            ));
4401            let expected = window_files
4402                .iter()
4403                .map(FileHandle::file_id)
4404                .collect::<HashSet<_>>();
4405            let picker = TwcsPicker {
4406                trigger_file_num: 4,
4407                active_window_l1_merge_trigger: 4,
4408                inactive_window_trigger_file_num: 2,
4409                inactive_window_l1_merge_trigger: 16,
4410                time_window_seconds: Some(60),
4411                max_output_file_size: Some(file_size),
4412                append_mode: false,
4413                merge_mode: MergeMode::LastRow,
4414                max_background_tasks: Some(1),
4415                time_range: None,
4416                all_windows_inactive: manual,
4417            };
4418            let mut files = window_files;
4419            files.push(l1_file(
4420                120_000,
4421                120_999,
4422                newer_sequence,
4423                file_size,
4424                pk_range(b"a0", b"h9"),
4425                1,
4426            ));
4427            let outputs = picker
4428                .build_output_with_time_range(
4429                    RegionId::from_u64(0),
4430                    assign_to_windows(files.iter(), 60),
4431                    manual.then_some(0),
4432                    Some(60),
4433                )
4434                .await
4435                .unwrap();
4436            let actual = outputs
4437                .iter()
4438                .flat_map(|output| output.inputs.iter().map(FileHandle::file_id))
4439                .collect::<HashSet<_>>();
4440            let case = format!("sequence {newer_sequence}, rows {num_rows}, manual {manual}");
4441            if resolves {
4442                assert_eq!(expected, actual, "{case}");
4443            } else {
4444                assert!(actual.is_empty(), "{case}: {actual:?}");
4445            }
4446        }
4447    }
4448
4449    #[test]
4450    fn test_count_first_prefers_guaranteed_reduction_over_pure_rewrite() {
4451        // A window mixing large and small files: the large triple on its own is a
4452        // pure rewrite, and every interval containing large files rewrites more
4453        // bytes for the same predicted reduction, so the small triple wins.
4454        let picked = pick_count_first(
4455            vec![SortedRun::from(vec![
4456                new_file(0, 9, 1, 600),
4457                new_file(10, 19, 2, 600),
4458                new_file(20, 29, 3, 600),
4459                new_file(30, 39, 4, 10),
4460                new_file(40, 49, 5, 10),
4461                new_file(50, 59, 6, 10),
4462            ])],
4463            Some(512),
4464        );
4465
4466        assert_eq!(vec![(30, 39), (40, 49), (50, 59)], picked_ranges(&picked));
4467    }
4468
4469    #[test]
4470    fn test_count_first_prefers_earlier_time_on_exact_tie() {
4471        let files = (0..=DEFAULT_MAX_INPUT_FILES)
4472            .map(|idx| {
4473                let start = idx as i64 * 20;
4474                new_file(start, start + 9, idx as u64 + 1, 10)
4475            })
4476            .collect::<Vec<_>>();
4477
4478        let picked = pick_count_first(vec![SortedRun::from(files)], None);
4479        let ranges = picked_ranges(&picked);
4480
4481        assert_eq!(DEFAULT_MAX_INPUT_FILES, ranges.len());
4482        assert_eq!(Some(&(0, 9)), ranges.first());
4483        let last_start = (DEFAULT_MAX_INPUT_FILES as i64 - 1) * 20;
4484        assert_eq!(Some(&(last_start, last_start + 9)), ranges.last());
4485    }
4486
4487    #[test]
4488    fn test_count_first_keeps_each_interleaved_run_contiguous() {
4489        let picked = pick_count_first(
4490            vec![
4491                SortedRun::from(vec![
4492                    new_file(0, 9, 1, 1),
4493                    new_file(20, 29, 2, 1),
4494                    new_file(40, 49, 3, 1),
4495                ]),
4496                SortedRun::from(vec![new_file(10, 19, 4, 1), new_file(30, 39, 5, 1)]),
4497            ],
4498            None,
4499        );
4500
4501        assert_eq!(
4502            vec![(0, 9), (10, 19), (20, 29), (30, 39), (40, 49)],
4503            picked_ranges(&picked)
4504        );
4505    }
4506
4507    // TODO(hl): TTL tester that checks if get_expired_ssts function works as expected.
4508
4509    // Reproduces the leftover-small-files deadlock observed in the compaction bench:
4510    // an "EngineFull" flush emits several SSTs sharing one sequence. They must be ordinary
4511    // candidates so a contiguous candidate can span them instead of treating them as an
4512    // indivisible block that fragments the timeline.
4513    #[test]
4514    fn test_count_first_candidate_spans_same_sequence_files() {
4515        let picked = pick_count_first(
4516            vec![SortedRun::from(vec![
4517                new_file(0, 9, 1, 10),
4518                // One EngineFull flush output: three SSTs with the same sequence.
4519                new_file(10, 19, 2, 10),
4520                new_file(10, 19, 2, 10),
4521                new_file(10, 19, 2, 10),
4522                new_file(20, 29, 3, 10),
4523            ])],
4524            None,
4525        );
4526
4527        assert_eq!(
4528            vec![(0, 9), (10, 19), (10, 19), (10, 19), (20, 29)],
4529            picked_ranges(&picked)
4530        );
4531    }
4532
4533    // End-to-end repro of the compaction-bench leftover files: a window where
4534    // EngineFull flush groups (multiple SSTs sharing a sequence) sit between
4535    // singleton flushes. The picker must be able to merge the whole contiguous
4536    // range; otherwise these files can never be compacted once the active window
4537    // moves on.
4538    #[tokio::test]
4539    async fn test_count_first_merges_window_with_interleaved_flush_groups() {
4540        let files = [
4541            // Singleton flush.
4542            new_file_handle_with_size_and_sequence(FileId::random(), 0, 9, 0, 1, 10),
4543            // EngineFull flush: three SSTs sharing sequence 2.
4544            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
4545            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
4546            new_file_handle_with_size_and_sequence(FileId::random(), 10, 19, 0, 2, 10),
4547            // Singleton flush.
4548            new_file_handle_with_size_and_sequence(FileId::random(), 20, 29, 0, 3, 10),
4549        ];
4550        let windows = assign_to_windows(files.iter(), 100);
4551        let picker = TwcsPicker {
4552            trigger_file_num: 3,
4553            active_window_l1_merge_trigger: 8,
4554            inactive_window_trigger_file_num: 3,
4555            inactive_window_l1_merge_trigger: 8,
4556            time_window_seconds: Some(100),
4557            max_output_file_size: None,
4558            append_mode: false,
4559            merge_mode: MergeMode::LastRow,
4560            max_background_tasks: None,
4561            time_range: None,
4562            all_windows_inactive: false,
4563        };
4564
4565        let output = picker
4566            .build_output_with_time_range(RegionId::from_u64(1), windows, Some(0), None)
4567            .await
4568            .unwrap();
4569
4570        assert_eq!(1, output.len());
4571        // All five SSTs of the window are merged in one compaction.
4572        assert_eq!(5, output[0].inputs.len());
4573    }
4574}