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