Skip to main content

mito2/compaction/
twcs.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::hash_map::Entry;
16use std::collections::{BTreeMap, HashMap, HashSet};
17use std::fmt::Debug;
18use std::num::NonZeroU64;
19use std::sync::Arc;
20
21use common_base::readable_size::ReadableSize;
22use common_telemetry::{debug, info};
23use common_time::Timestamp;
24use common_time::range::TimestampRange;
25use common_time::timestamp::TimeUnit;
26use common_time::timestamp_millis::BucketAligned;
27use snafu::ResultExt;
28use store_api::storage::RegionId;
29
30use crate::compaction::CompactionOutput;
31use crate::compaction::buckets::infer_time_bucket;
32use crate::compaction::compactor::CompactionRegion;
33use crate::compaction::picker::{Picker, PickerOutput, get_expired_ssts};
34use crate::compaction::run::{
35    FileGroup, Item, Ranged, find_sorted_runs, find_sorted_runs_by_time_range,
36    merge_primary_key_ranges, merge_seq_files, primary_key_ranges_overlap, reduce_runs,
37};
38use crate::error::{JoinSnafu, Result};
39use crate::sst::file::{FileHandle, Level, overlaps};
40use crate::sst::version::LevelMeta;
41
42const LEVEL_COMPACTED: Level = 1;
43
44/// Default value for max compaction input file num.
45const DEFAULT_MAX_INPUT_FILE_NUM: usize = 32;
46
47/// `TwcsPicker` picks files of which the max timestamp are in the same time window as compaction
48/// candidates.
49#[derive(Clone, Debug)]
50pub struct TwcsPicker {
51    /// Minimum file num to trigger a compaction.
52    pub trigger_file_num: usize,
53    /// Compaction time window in seconds.
54    pub time_window_seconds: Option<i64>,
55    /// Max allowed compaction output file size.
56    pub max_output_file_size: Option<u64>,
57    /// Whether the target region is in append mode.
58    pub append_mode: bool,
59    /// Max background compaction tasks.
60    pub max_background_tasks: Option<usize>,
61    /// Optional time range that constrains candidate compaction windows.
62    pub(crate) time_range: Option<TimestampRange>,
63}
64
65impl TwcsPicker {
66    async fn build_output_with_time_range(
67        &self,
68        region_id: RegionId,
69        time_windows: BTreeMap<i64, Window>,
70        active_window: Option<i64>,
71        time_window_size: Option<i64>,
72    ) -> Result<Vec<CompactionOutput>> {
73        let mut output = vec![];
74        let windows = time_windows
75            .values()
76            .rev()
77            .filter(|window| {
78                !window.files.is_empty()
79                    && self.time_range.as_ref().is_none_or(|time_range| {
80                        time_window_size.is_none_or(|time_window_size| {
81                            time_window_intersects_range(
82                                window.time_window,
83                                time_window_size,
84                                time_range,
85                            )
86                        })
87                    })
88            })
89            .map(|window| window.time_window)
90            .collect::<Vec<_>>();
91        let time_windows = Arc::new(time_windows);
92        let chunk_size = self.max_background_tasks.unwrap_or(windows.len()).max(1);
93        'chunks: for chunk in windows.chunks(chunk_size) {
94            let mut handles = Vec::with_capacity(chunk.len());
95            for window in chunk {
96                let picker = self.clone();
97                let time_windows = time_windows.clone();
98                let window = *window;
99                handles.push(common_runtime::spawn_blocking_compact(move || {
100                    time_windows.get(&window).map(|window| {
101                        picker.find_inputs(region_id, active_window, window, &time_windows)
102                    })
103                }));
104                tokio::task::yield_now().await;
105            }
106            for result in futures::future::join_all(handles).await {
107                let Some((inputs, filter_deleted)) = result.context(JoinSnafu)? else {
108                    continue;
109                };
110                if inputs.is_empty() {
111                    continue;
112                }
113
114                output.push(CompactionOutput {
115                    output_level: LEVEL_COMPACTED, // always compact to l1
116                    inputs: inputs.into_iter().flat_map(|fg| fg.into_files()).collect(),
117                    filter_deleted,
118                    output_time_range: None, // we do not enforce output time range in twcs compactions.
119                });
120
121                if let Some(max_background_tasks) = self.max_background_tasks
122                    && output.len() >= max_background_tasks
123                {
124                    debug!(
125                        "Region ({:?}) compaction task size larger than max background tasks({}), remaining tasks discarded",
126                        region_id, max_background_tasks
127                    );
128                    break 'chunks;
129                }
130            }
131        }
132        Ok(output)
133    }
134
135    fn find_inputs(
136        &self,
137        region_id: RegionId,
138        active_window: Option<i64>,
139        files: &Window,
140        windows: &BTreeMap<i64, Window>,
141    ) -> (Vec<FileGroup>, bool) {
142        let window = &files.time_window;
143        let mut files_to_merge: Vec<_> = files.files().cloned().collect();
144
145        // Filter out large files in append mode - they won't benefit from compaction
146        if self.append_mode
147            && let Some(max_size) = self.max_output_file_size
148        {
149            let (kept_files, ignored_files) = files_to_merge
150                .into_iter()
151                .partition(|fg| fg.size() <= max_size as usize);
152            files_to_merge = kept_files;
153            if !ignored_files.is_empty() {
154                info!(
155                    "Skipped {} large files in append mode for region {}, window {}, max_size: {}",
156                    ignored_files.len(),
157                    region_id,
158                    window,
159                    max_size
160                );
161            }
162        }
163
164        let sorted_runs = if files_to_merge.len() < 1024 {
165            find_sorted_runs(&mut files_to_merge)
166        } else {
167            find_sorted_runs_by_time_range(&mut files_to_merge)
168        };
169        let found_runs = sorted_runs.len();
170        // We only remove deletion markers if we found less than 2 runs and not in append mode.
171        // because after compaction there will be no overlapping files.
172        let filter_deleted =
173            found_runs <= 2 && !self.append_mode && !window_has_overlap(files, windows);
174        if found_runs == 0 {
175            return (vec![], filter_deleted);
176        }
177
178        let mut inputs = if found_runs > 1 {
179            reduce_runs(sorted_runs)
180        } else {
181            let run = sorted_runs.last().unwrap();
182            if run.items().len() < self.trigger_file_num {
183                return (vec![], filter_deleted);
184            }
185            // no overlapping files, try merge small files
186            merge_seq_files(run.items(), self.max_output_file_size)
187        };
188
189        // Limits the number of input files.
190        let total_input_files: usize = inputs.iter().map(|fg| fg.num_files()).sum();
191        if total_input_files > DEFAULT_MAX_INPUT_FILE_NUM {
192            // Sorts file groups by size first.
193            inputs.sort_unstable_by_key(|fg| fg.size());
194            let mut num_picked_files = 0;
195            inputs = inputs
196                .into_iter()
197                .take_while(|fg| {
198                    let current_group_file_num = fg.num_files();
199                    if current_group_file_num + num_picked_files <= DEFAULT_MAX_INPUT_FILE_NUM {
200                        num_picked_files += current_group_file_num;
201                        true
202                    } else {
203                        false
204                    }
205                })
206                .collect::<Vec<_>>();
207            info!(
208                "Compaction for region {} enforces max input file num limit: {}, current total: {}, input: {:?}",
209                region_id, DEFAULT_MAX_INPUT_FILE_NUM, total_input_files, inputs
210            );
211        }
212
213        if inputs.len() > 1 {
214            // If we have more than one group to compact.
215            log_pick_result(
216                region_id,
217                *window,
218                active_window,
219                found_runs,
220                files.files.len(),
221                self.max_output_file_size,
222                filter_deleted,
223                &inputs,
224            );
225        }
226        (inputs, filter_deleted)
227    }
228}
229
230#[allow(clippy::too_many_arguments)]
231fn log_pick_result(
232    region_id: RegionId,
233    window: i64,
234    active_window: Option<i64>,
235    found_runs: usize,
236    file_num: usize,
237    max_output_file_size: Option<u64>,
238    filter_deleted: bool,
239    inputs: &[FileGroup],
240) {
241    let input_file_str: Vec<String> = inputs
242        .iter()
243        .map(|f| {
244            let range = f.range();
245            let start = range.0.to_iso8601_string();
246            let end = range.1.to_iso8601_string();
247            let num_rows = f.num_rows();
248            format!(
249                "FileGroup{{id: {:?}, range: ({}, {}), size: {}, num rows: {} }}",
250                f.file_ids(),
251                start,
252                end,
253                ReadableSize(f.size() as u64),
254                num_rows
255            )
256        })
257        .collect();
258    let window_str = Timestamp::new_second(window).to_iso8601_string();
259    let active_window_str = active_window.map(|s| Timestamp::new_second(s).to_iso8601_string());
260    let max_output_file_size = max_output_file_size.map(|size| ReadableSize(size).to_string());
261    info!(
262        "Region ({:?}) compaction pick result: current window: {}, active window: {:?}, \
263            found runs: {}, file num: {}, max output file size: {:?}, filter deleted: {}, \
264            input files: {:?}",
265        region_id,
266        window_str,
267        active_window_str,
268        found_runs,
269        file_num,
270        max_output_file_size,
271        filter_deleted,
272        input_file_str
273    );
274}
275
276#[async_trait::async_trait]
277impl Picker for TwcsPicker {
278    async fn pick(&self, compaction_region: &CompactionRegion) -> Result<Option<PickerOutput>> {
279        let region_id = compaction_region.region_id;
280        let picker = self.clone();
281        let compaction_region = compaction_region.clone();
282        let (expired_ssts, time_window_size, active_window, windows) =
283            common_runtime::spawn_blocking_compact(move || {
284                let levels = compaction_region.current_version.ssts.levels();
285                let expired_ssts = get_expired_ssts(
286                    levels,
287                    compaction_region.ttl,
288                    Timestamp::current_millis(),
289                );
290                if !expired_ssts.is_empty() {
291                    info!("Expired SSTs in region {}: {:?}", region_id, expired_ssts);
292                }
293                let expired_file_ids = expired_ssts
294                    .iter()
295                    .map(|file| file.file_id())
296                    .collect::<HashSet<_>>();
297
298                let compaction_time_window = compaction_region
299                    .current_version
300                    .compaction_time_window
301                    .map(|window| window.as_secs() as i64);
302                let time_window_size = compaction_time_window
303                    .or(picker.time_window_seconds)
304                    .unwrap_or_else(|| {
305                        let inferred = infer_time_bucket(levels[0].files());
306                        info!(
307                            "Compaction window for region {} is not present, inferring from files: {:?}",
308                            region_id, inferred
309                        );
310                        inferred
311                    });
312
313                let active_window =
314                    find_latest_window_in_seconds(levels[0].files(), time_window_size);
315                let windows = assign_to_windows(
316                    levels
317                        .iter()
318                        .flat_map(LevelMeta::files)
319                        .filter(|file| !expired_file_ids.contains(&file.file_id())),
320                    time_window_size,
321                );
322
323                (expired_ssts, time_window_size, active_window, windows)
324            })
325            .await
326            .context(JoinSnafu)?;
327
328        let outputs = self
329            .build_output_with_time_range(region_id, windows, active_window, Some(time_window_size))
330            .await?;
331
332        if outputs.is_empty() && expired_ssts.is_empty() {
333            return Ok(None);
334        }
335
336        let max_file_size = self.max_output_file_size.map(|v| v as usize);
337        Ok(Some(PickerOutput {
338            outputs,
339            expired_ssts,
340            time_window_size,
341            max_file_size,
342        }))
343    }
344}
345
346#[derive(Clone)]
347struct Window {
348    start: Timestamp,
349    end: Timestamp,
350    // Mapping from file sequence to file groups. Files with the same sequence is considered
351    // created from the same compaction task.
352    files: HashMap<Option<NonZeroU64>, FileGroup>,
353    time_window: i64,
354    primary_key_range: Option<(bytes::Bytes, bytes::Bytes)>,
355}
356
357impl Window {
358    /// Creates a new [Window] with given file.
359    fn new_with_file(file: FileHandle) -> Self {
360        let (start, end) = file.time_range();
361        let primary_key_range = file.primary_key_range();
362        let files = HashMap::from([(file.meta_ref().sequence, FileGroup::new_with_file(file))]);
363        Self {
364            start,
365            end,
366            files,
367            time_window: 0,
368            primary_key_range,
369        }
370    }
371
372    /// Returns the time range of all files in current window (inclusive).
373    fn range(&self) -> (Timestamp, Timestamp) {
374        (self.start, self.end)
375    }
376
377    /// Adds a new file to window and updates time range.
378    fn add_file(&mut self, file: FileHandle) {
379        let (start, end) = file.time_range();
380        self.start = self.start.min(start);
381        self.end = self.end.max(end);
382        self.primary_key_range =
383            merge_primary_key_ranges(self.primary_key_range.take(), file.primary_key_range());
384
385        match self.files.entry(file.meta_ref().sequence) {
386            Entry::Occupied(mut o) => {
387                o.get_mut().add_file(file);
388            }
389            Entry::Vacant(v) => {
390                v.insert(FileGroup::new_with_file(file));
391            }
392        }
393    }
394
395    fn files(&self) -> impl Iterator<Item = &FileGroup> {
396        self.files.values()
397    }
398}
399
400/// Assigns files to windows with predefined window size (in seconds) by their max timestamps.
401fn assign_to_windows<'a>(
402    files: impl Iterator<Item = &'a FileHandle>,
403    time_window_size: i64,
404) -> BTreeMap<i64, Window> {
405    let mut windows: HashMap<i64, Window> = HashMap::new();
406    // Iterates all files and assign to time windows according to max timestamp
407    for f in files {
408        if f.compacting() {
409            continue;
410        }
411        let (_, end) = f.time_range();
412        let time_window = end
413            .convert_to(TimeUnit::Second)
414            .unwrap()
415            .value()
416            .align_to_ceil_by_bucket(time_window_size)
417            .unwrap_or(i64::MIN);
418
419        match windows.entry(time_window) {
420            Entry::Occupied(mut e) => {
421                e.get_mut().add_file(f.clone());
422            }
423            Entry::Vacant(e) => {
424                let mut window = Window::new_with_file(f.clone());
425                window.time_window = time_window;
426                e.insert(window);
427            }
428        }
429    }
430    windows.into_iter().collect()
431}
432
433fn time_window_intersects_range(
434    window_end: i64,
435    time_window_size: i64,
436    time_range: &TimestampRange,
437) -> bool {
438    let first_window = match time_range.start() {
439        None => i64::MIN,
440        Some(start) => {
441            let Some(first_window) = start
442                .convert_to(TimeUnit::Second)
443                .and_then(|timestamp| timestamp.value().align_to_ceil_by_bucket(time_window_size))
444            else {
445                return false;
446            };
447            first_window
448        }
449    };
450    let last_window = match time_range.end() {
451        None => i64::MAX,
452        Some(end) => {
453            let Some(last_window) = end
454                .convert_to_ceil(TimeUnit::Second)
455                .and_then(|timestamp| timestamp.value().checked_sub(1))
456                .and_then(|timestamp| timestamp.align_to_ceil_by_bucket(time_window_size))
457            else {
458                return false;
459            };
460            last_window
461        }
462    };
463    (first_window..=last_window).contains(&window_end)
464}
465
466fn window_has_overlap(this: &Window, windows: &BTreeMap<i64, Window>) -> bool {
467    windows
468        .values()
469        .filter(|that| this.time_window != that.time_window)
470        .any(|that| {
471            overlaps(&this.range(), &that.range()) && {
472                match (&this.primary_key_range, &that.primary_key_range) {
473                    (Some(l), Some(r)) => primary_key_ranges_overlap(l, r),
474                    _ => true,
475                }
476            }
477        })
478}
479
480/// Finds the latest active writing window among all files.
481/// Returns `None` when there are no files or all files are corrupted.
482fn find_latest_window_in_seconds<'a>(
483    files: impl Iterator<Item = &'a FileHandle>,
484    time_window_size: i64,
485) -> Option<i64> {
486    let mut latest_timestamp = None;
487    for f in files {
488        let (_, end) = f.time_range();
489        if let Some(latest) = latest_timestamp {
490            if end > latest {
491                latest_timestamp = Some(end);
492            }
493        } else {
494            latest_timestamp = Some(end);
495        }
496    }
497    latest_timestamp
498        .and_then(|ts| ts.convert_to_ceil(TimeUnit::Second))
499        .and_then(|ts| ts.value().align_to_ceil_by_bucket(time_window_size))
500}
501
502#[cfg(test)]
503mod tests {
504    use std::collections::HashSet;
505    use std::num::NonZeroU64;
506    use std::sync::Arc;
507    use std::time::Duration;
508
509    use bytes::Bytes;
510    use common_base::Plugins;
511    use common_time::range::TimestampRange;
512    use store_api::storage::FileId;
513
514    use super::*;
515    use crate::cache::CacheManager;
516    use crate::compaction::compactor::CompactionVersion;
517    use crate::compaction::test_util::{
518        new_file_handle, new_file_handle_with_sequence, new_file_handle_with_size_and_sequence,
519        new_file_handle_with_size_sequence_and_primary_key_range,
520    };
521    use crate::config::MitoConfig;
522    use crate::region::options::RegionOptions;
523    use crate::sst::file::{FileMeta, Level};
524    use crate::sst::version::SstVersion;
525    use crate::test_util::memtable_util::metadata_for_test;
526    use crate::test_util::scheduler_util::SchedulerEnv;
527
528    async fn compaction_region_with_expired_sst() -> CompactionRegion {
529        let env = SchedulerEnv::new().await;
530        let metadata = metadata_for_test();
531        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
532        let mut ssts = SstVersion::new();
533        ssts.add_files(
534            Arc::new(crate::sst::file_purger::NoopFilePurger),
535            (1..=4).map(|sequence| FileMeta {
536                file_id: FileId::random(),
537                time_range: (
538                    Timestamp::new_millisecond(0),
539                    Timestamp::new_millisecond(10),
540                ),
541                level: 0,
542                sequence: NonZeroU64::new(sequence),
543                ..Default::default()
544            }),
545        );
546
547        CompactionRegion {
548            region_id: metadata.region_id,
549            region_options: RegionOptions::default(),
550            engine_config: Arc::new(MitoConfig::default()),
551            region_metadata: metadata.clone(),
552            cache_manager: Arc::new(CacheManager::default()),
553            access_layer: env.access_layer,
554            manifest_ctx,
555            current_version: CompactionVersion {
556                metadata,
557                options: RegionOptions::default(),
558                ssts: Arc::new(ssts),
559                compaction_time_window: None,
560            },
561            file_purger: None,
562            ttl: Some(Duration::from_millis(1).into()),
563            max_parallelism: 1,
564            plugins: Plugins::new(),
565        }
566    }
567
568    #[tokio::test]
569    async fn test_pick_expired_ssts_without_marking_compacting() {
570        let picker = TwcsPicker {
571            trigger_file_num: 4,
572            time_window_seconds: Some(3),
573            max_output_file_size: None,
574            append_mode: false,
575            max_background_tasks: None,
576            time_range: None,
577        };
578        let compaction_region = compaction_region_with_expired_sst().await;
579
580        let output = picker.pick(&compaction_region).await.unwrap().unwrap();
581
582        assert!(output.outputs.is_empty());
583        assert!(!output.expired_ssts.is_empty());
584        assert!(output.expired_ssts.iter().all(|file| !file.compacting()));
585    }
586
587    #[test]
588    fn test_get_latest_window_in_seconds() {
589        assert_eq!(
590            Some(1),
591            find_latest_window_in_seconds([new_file_handle(FileId::random(), 0, 999, 0)].iter(), 1)
592        );
593        assert_eq!(
594            Some(1),
595            find_latest_window_in_seconds(
596                [new_file_handle(FileId::random(), 0, 1000, 0)].iter(),
597                1
598            )
599        );
600
601        assert_eq!(
602            Some(-9223372036854000),
603            find_latest_window_in_seconds(
604                [new_file_handle(FileId::random(), i64::MIN, i64::MIN + 1, 0)].iter(),
605                3600,
606            )
607        );
608
609        assert_eq!(
610            (i64::MAX / 10000000 + 1) * 10000,
611            find_latest_window_in_seconds(
612                [new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0)].iter(),
613                10000,
614            )
615            .unwrap()
616        );
617
618        assert_eq!(
619            Some((i64::MAX / 3600000 + 1) * 3600),
620            find_latest_window_in_seconds(
621                [
622                    new_file_handle(FileId::random(), i64::MIN, i64::MAX, 0),
623                    new_file_handle(FileId::random(), 0, 1000, 0)
624                ]
625                .iter(),
626                3600
627            )
628        );
629    }
630
631    #[test]
632    fn test_assign_to_windows() {
633        let windows = assign_to_windows(
634            [
635                new_file_handle(FileId::random(), 0, 999, 0),
636                new_file_handle(FileId::random(), 0, 999, 0),
637                new_file_handle(FileId::random(), 0, 999, 0),
638                new_file_handle(FileId::random(), 0, 999, 0),
639                new_file_handle(FileId::random(), 0, 999, 0),
640            ]
641            .iter(),
642            3,
643        );
644        let fgs = &windows.get(&0).unwrap().files;
645        assert_eq!(1, fgs.len());
646        assert_eq!(fgs.values().map(|f| f.files().len()).sum::<usize>(), 5);
647
648        let files = [FileId::random(); 3];
649        let windows = assign_to_windows(
650            [
651                new_file_handle(files[0], -2000, -3, 0),
652                new_file_handle(files[1], 0, 2999, 0),
653                new_file_handle(files[2], 50, 10001, 0),
654            ]
655            .iter(),
656            3,
657        );
658        assert_eq!(
659            files[0],
660            windows.get(&0).unwrap().files().next().unwrap().files()[0]
661                .file_id()
662                .file_id()
663        );
664        assert_eq!(
665            files[1],
666            windows.get(&3).unwrap().files().next().unwrap().files()[0]
667                .file_id()
668                .file_id()
669        );
670        assert_eq!(
671            files[2],
672            windows.get(&12).unwrap().files().next().unwrap().files()[0]
673                .file_id()
674                .file_id()
675        );
676    }
677
678    #[test]
679    fn test_assign_file_groups_to_windows() {
680        let files = [
681            FileId::random(),
682            FileId::random(),
683            FileId::random(),
684            FileId::random(),
685        ];
686        let windows = assign_to_windows(
687            [
688                new_file_handle_with_sequence(files[0], 0, 999, 0, 1),
689                new_file_handle_with_sequence(files[1], 0, 999, 0, 1),
690                new_file_handle_with_sequence(files[2], 0, 999, 0, 2),
691                new_file_handle_with_sequence(files[3], 0, 999, 0, 2),
692            ]
693            .iter(),
694            3,
695        );
696        assert_eq!(windows.len(), 1);
697        let fgs = &windows.get(&0).unwrap().files;
698        assert_eq!(2, fgs.len());
699        assert_eq!(
700            fgs.get(&NonZeroU64::new(1))
701                .unwrap()
702                .files()
703                .iter()
704                .map(|f| f.file_id().file_id())
705                .collect::<HashSet<_>>(),
706            [files[0], files[1]].into_iter().collect()
707        );
708        assert_eq!(
709            fgs.get(&NonZeroU64::new(2))
710                .unwrap()
711                .files()
712                .iter()
713                .map(|f| f.file_id().file_id())
714                .collect::<HashSet<_>>(),
715            [files[2], files[3]].into_iter().collect()
716        );
717    }
718
719    #[test]
720    fn test_assign_compacting_to_windows() {
721        let files = [
722            new_file_handle(FileId::random(), 0, 999, 0),
723            new_file_handle(FileId::random(), 0, 999, 0),
724            new_file_handle(FileId::random(), 0, 999, 0),
725            new_file_handle(FileId::random(), 0, 999, 0),
726            new_file_handle(FileId::random(), 0, 999, 0),
727        ];
728        files[0].set_compacting(true);
729        files[2].set_compacting(true);
730        let mut windows = assign_to_windows(files.iter(), 3);
731        let window0 = windows.remove(&0).unwrap();
732        assert_eq!(1, window0.files.len());
733        let candidates = window0
734            .files
735            .into_values()
736            .flat_map(|fg| fg.into_files())
737            .map(|f| f.file_id().file_id())
738            .collect::<HashSet<_>>();
739        assert_eq!(candidates.len(), 3);
740        assert_eq!(
741            candidates,
742            [
743                files[1].file_id().file_id(),
744                files[3].file_id().file_id(),
745                files[4].file_id().file_id()
746            ]
747            .into_iter()
748            .collect::<HashSet<_>>()
749        );
750    }
751
752    /// (Window value, overlapping, files' time ranges in window)
753    type ExpectedWindowSpec = (i64, bool, Vec<(i64, i64)>);
754
755    fn pk_range(min: &'static [u8], max: &'static [u8]) -> Option<(Bytes, Bytes)> {
756        Some((Bytes::from_static(min), Bytes::from_static(max)))
757    }
758
759    fn check_assign_to_windows_with_overlapping(
760        file_time_ranges: &[(i64, i64)],
761        time_window: i64,
762        expected_files: &[ExpectedWindowSpec],
763    ) {
764        let files: Vec<_> = (0..file_time_ranges.len())
765            .map(|_| FileId::random())
766            .collect();
767
768        let file_handles = files
769            .iter()
770            .zip(file_time_ranges.iter())
771            .map(|(file_id, range)| new_file_handle(*file_id, range.0, range.1, 0))
772            .collect::<Vec<_>>();
773
774        let windows = assign_to_windows(file_handles.iter(), time_window);
775
776        for (expected_window, overlapping, window_files) in expected_files {
777            let actual_window = windows.get(expected_window).unwrap();
778            let actual_overlapping = window_has_overlap(actual_window, &windows);
779            assert_eq!(*overlapping, actual_overlapping);
780            let mut file_ranges = actual_window
781                .files
782                .values()
783                .flat_map(|f| {
784                    f.files().iter().map(|f| {
785                        let (s, e) = f.time_range();
786                        (s.value(), e.value())
787                    })
788                })
789                .collect::<Vec<_>>();
790            file_ranges.sort_unstable_by(|l, r| l.0.cmp(&r.0).then(l.1.cmp(&r.1)));
791            assert_eq!(window_files, &file_ranges);
792        }
793    }
794
795    #[test]
796    fn test_assign_to_windows_with_overlapping() {
797        check_assign_to_windows_with_overlapping(
798            &[(0, 999), (1000, 1999), (2000, 2999)],
799            2,
800            &[
801                (0, false, vec![(0, 999)]),
802                (2, false, vec![(1000, 1999), (2000, 2999)]),
803            ],
804        );
805
806        check_assign_to_windows_with_overlapping(
807            &[(0, 1), (0, 999), (100, 2999)],
808            2,
809            &[
810                (0, true, vec![(0, 1), (0, 999)]),
811                (2, true, vec![(100, 2999)]),
812            ],
813        );
814
815        check_assign_to_windows_with_overlapping(
816            &[(0, 999), (1000, 1999), (2000, 2999), (3000, 3999)],
817            2,
818            &[
819                (0, false, vec![(0, 999)]),
820                (2, false, vec![(1000, 1999), (2000, 2999)]),
821                (4, false, vec![(3000, 3999)]),
822            ],
823        );
824
825        check_assign_to_windows_with_overlapping(
826            &[
827                (0, 999),
828                (1000, 1999),
829                (2000, 2999),
830                (3000, 3999),
831                (0, 3999),
832            ],
833            2,
834            &[
835                (0, true, vec![(0, 999)]),
836                (2, true, vec![(1000, 1999), (2000, 2999)]),
837                (4, true, vec![(0, 3999), (3000, 3999)]),
838            ],
839        );
840
841        check_assign_to_windows_with_overlapping(
842            &[
843                (0, 999),
844                (1000, 1999),
845                (2000, 2999),
846                (3000, 3999),
847                (1999, 3999),
848            ],
849            2,
850            &[
851                (0, false, vec![(0, 999)]),
852                (2, true, vec![(1000, 1999), (2000, 2999)]),
853                (4, true, vec![(1999, 3999), (3000, 3999)]),
854            ],
855        );
856
857        check_assign_to_windows_with_overlapping(
858            &[
859                (0, 999),     // window 0
860                (1000, 1999), // window 2
861                (2000, 2999), // window 2
862                (3000, 3999), // window 4
863                (2999, 3999), // window 4
864            ],
865            2,
866            &[
867                // window 2 overlaps with window 4
868                (0, false, vec![(0, 999)]),
869                (2, true, vec![(1000, 1999), (2000, 2999)]),
870                (4, true, vec![(2999, 3999), (3000, 3999)]),
871            ],
872        );
873
874        check_assign_to_windows_with_overlapping(
875            &[
876                (0, 999),     // window 0
877                (1000, 1999), // window 2
878                (2000, 2999), // window 2
879                (3000, 3999), // window 4
880                (0, 1000),    // // window 2
881            ],
882            2,
883            &[
884                // only window 0 overlaps with window 2.
885                (0, true, vec![(0, 999)]),
886                (2, true, vec![(0, 1000), (1000, 1999), (2000, 2999)]),
887                (4, false, vec![(3000, 3999)]),
888            ],
889        );
890    }
891
892    #[test]
893    fn test_assign_to_windows_not_overlapping_when_pk_disjoint() {
894        let files = [
895            new_file_handle_with_size_sequence_and_primary_key_range(
896                FileId::random(),
897                0,
898                1000,
899                0,
900                1,
901                10,
902                pk_range(b"a", b"f"),
903            ),
904            new_file_handle_with_size_sequence_and_primary_key_range(
905                FileId::random(),
906                500,
907                1999,
908                0,
909                2,
910                10,
911                pk_range(b"x", b"z"),
912            ),
913        ];
914
915        let windows = assign_to_windows(files.iter(), 2);
916
917        let overlapping = window_has_overlap(windows.get(&2).unwrap(), &windows);
918        assert!(!overlapping);
919    }
920
921    #[test]
922    fn test_assign_to_windows_pk_unknown_in_earlier_window_does_not_poison_later_windows() {
923        let files = [
924            new_file_handle(FileId::random(), 0, 1999, 0),
925            new_file_handle_with_size_sequence_and_primary_key_range(
926                FileId::random(),
927                2000,
928                3999,
929                0,
930                1,
931                10,
932                pk_range(b"a", b"f"),
933            ),
934            new_file_handle_with_size_sequence_and_primary_key_range(
935                FileId::random(),
936                3000,
937                4999,
938                0,
939                2,
940                10,
941                pk_range(b"x", b"z"),
942            ),
943        ];
944
945        let windows = assign_to_windows(files.iter(), 2);
946
947        let overlapping = window_has_overlap(windows.get(&4).unwrap(), &windows);
948        assert!(!overlapping);
949    }
950
951    struct CompactionPickerTestCase {
952        window_size: i64,
953        input_files: Vec<FileHandle>,
954        expected_outputs: Vec<ExpectedOutput>,
955    }
956
957    impl CompactionPickerTestCase {
958        async fn check(&self) {
959            let file_id_to_idx = self
960                .input_files
961                .iter()
962                .enumerate()
963                .map(|(idx, file)| (file.file_id(), idx))
964                .collect::<HashMap<_, _>>();
965            let windows = assign_to_windows(self.input_files.iter(), self.window_size);
966            let active_window =
967                find_latest_window_in_seconds(self.input_files.iter(), self.window_size);
968            let output = TwcsPicker {
969                trigger_file_num: 4,
970                time_window_seconds: None,
971                max_output_file_size: None,
972                append_mode: false,
973                max_background_tasks: None,
974                time_range: None,
975            }
976            .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
977            .await
978            .unwrap();
979
980            let output = output
981                .iter()
982                .map(|o| {
983                    let input_file_ids = o
984                        .inputs
985                        .iter()
986                        .map(|f| file_id_to_idx.get(&f.file_id()).copied().unwrap())
987                        .collect::<HashSet<_>>();
988                    (input_file_ids, o.output_level)
989                })
990                .collect::<Vec<_>>();
991
992            let expected = self
993                .expected_outputs
994                .iter()
995                .map(|o| {
996                    let input_file_ids = o.input_files.iter().copied().collect::<HashSet<_>>();
997                    (input_file_ids, o.output_level)
998                })
999                .collect::<Vec<_>>();
1000            assert_eq!(expected, output);
1001        }
1002    }
1003
1004    struct ExpectedOutput {
1005        input_files: Vec<usize>,
1006        output_level: Level,
1007    }
1008
1009    #[tokio::test]
1010    async fn test_build_twcs_output() {
1011        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
1012
1013        // Case 1: 2 runs found in each time window.
1014        CompactionPickerTestCase {
1015            window_size: 3,
1016            input_files: [
1017                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
1018                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
1019                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3), //active windows
1020                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4), //active windows
1021            ]
1022            .to_vec(),
1023            expected_outputs: vec![
1024                ExpectedOutput {
1025                    input_files: vec![2, 3],
1026                    output_level: 1,
1027                },
1028                ExpectedOutput {
1029                    input_files: vec![0, 1],
1030                    output_level: 1,
1031                },
1032            ],
1033        }
1034        .check()
1035        .await;
1036
1037        // Case 2:
1038        //    -2000........-3
1039        // -3000.....-100
1040        //                    0..............2999
1041        //                      50..........2998
1042        //                     11.........2990
1043        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
1044        CompactionPickerTestCase {
1045            window_size: 3,
1046            input_files: [
1047                new_file_handle_with_sequence(file_ids[0], -2000, -3, 0, 1),
1048                new_file_handle_with_sequence(file_ids[1], -3000, -100, 0, 2),
1049                new_file_handle_with_sequence(file_ids[2], 0, 2999, 0, 3),
1050                new_file_handle_with_sequence(file_ids[3], 50, 2998, 0, 4),
1051                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 5),
1052            ]
1053            .to_vec(),
1054            expected_outputs: vec![
1055                ExpectedOutput {
1056                    input_files: vec![2, 4],
1057                    output_level: 1,
1058                },
1059                ExpectedOutput {
1060                    input_files: vec![0, 1],
1061                    output_level: 1,
1062                },
1063            ],
1064        }
1065        .check()
1066        .await;
1067
1068        // Case 3:
1069        // A compaction may split output into several files that have overlapping time ranges and same sequence,
1070        // we should treat these files as one FileGroup.
1071        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
1072        CompactionPickerTestCase {
1073            window_size: 3,
1074            input_files: [
1075                new_file_handle_with_sequence(file_ids[0], 0, 2999, 1, 1),
1076                new_file_handle_with_sequence(file_ids[1], 0, 2998, 1, 1),
1077                new_file_handle_with_sequence(file_ids[2], 3000, 5999, 1, 2),
1078                new_file_handle_with_sequence(file_ids[3], 3000, 5000, 1, 2),
1079                new_file_handle_with_sequence(file_ids[4], 11, 2990, 0, 3),
1080            ]
1081            .to_vec(),
1082            expected_outputs: vec![ExpectedOutput {
1083                input_files: vec![0, 1, 4],
1084                output_level: 1,
1085            }],
1086        }
1087        .check()
1088        .await;
1089    }
1090
1091    #[tokio::test]
1092    async fn test_build_output_skips_pk_disjoint_files() {
1093        let files = [
1094            new_file_handle_with_size_sequence_and_primary_key_range(
1095                FileId::random(),
1096                0,
1097                2999,
1098                0,
1099                1,
1100                10,
1101                pk_range(b"a", b"f"),
1102            ),
1103            new_file_handle_with_size_sequence_and_primary_key_range(
1104                FileId::random(),
1105                50,
1106                2998,
1107                0,
1108                2,
1109                10,
1110                pk_range(b"x", b"z"),
1111            ),
1112        ];
1113        let windows = assign_to_windows(files.iter(), 3);
1114        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1115        let output = TwcsPicker {
1116            trigger_file_num: 4,
1117            time_window_seconds: None,
1118            max_output_file_size: None,
1119            append_mode: false,
1120            max_background_tasks: None,
1121            time_range: None,
1122        }
1123        .build_output_with_time_range(RegionId::from_u64(0), windows, active_window, None)
1124        .await
1125        .unwrap();
1126
1127        assert!(output.is_empty());
1128    }
1129
1130    #[test]
1131    fn test_append_mode_filter_large_files() {
1132        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
1133        let max_output_file_size = 1000u64;
1134
1135        // Create files with different sizes
1136        let small_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 500);
1137        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 1500);
1138        let small_file_2 = new_file_handle_with_size_and_sequence(file_ids[2], 0, 999, 0, 3, 800);
1139        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[3], 0, 999, 0, 4, 2000);
1140
1141        // Create file groups (each file is in its own group due to different sequences)
1142        let mut files_to_merge = vec![
1143            FileGroup::new_with_file(small_file_1),
1144            FileGroup::new_with_file(large_file_1),
1145            FileGroup::new_with_file(small_file_2),
1146            FileGroup::new_with_file(large_file_2),
1147        ];
1148
1149        // Test filtering logic directly
1150        let original_count = files_to_merge.len();
1151
1152        // Apply append mode filtering
1153        files_to_merge.retain(|fg| fg.size() <= max_output_file_size as usize);
1154
1155        // Should have filtered out 2 large files, leaving 2 small files
1156        assert_eq!(files_to_merge.len(), 2);
1157        assert_eq!(original_count, 4);
1158
1159        // Verify the remaining files are the small ones
1160        for fg in &files_to_merge {
1161            assert!(
1162                fg.size() <= max_output_file_size as usize,
1163                "File size {} should be <= {}",
1164                fg.size(),
1165                max_output_file_size
1166            );
1167        }
1168    }
1169
1170    #[tokio::test]
1171    async fn test_build_output_multiple_windows_with_zero_runs() {
1172        let file_ids = (0..6).map(|_| FileId::random()).collect::<Vec<_>>();
1173
1174        let files = [
1175            // Window 0: Contains 3 files but not forming any runs (not enough files in sequence to reach trigger_file_num)
1176            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
1177            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
1178            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
1179            // Window 3: Contains files that will form 2 runs
1180            new_file_handle_with_sequence(file_ids[3], 3000, 3999, 0, 4),
1181            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
1182            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
1183        ];
1184
1185        let windows = assign_to_windows(files.iter(), 3);
1186
1187        // Create picker with trigger_file_num of 4 so single files won't form runs in first window
1188        let picker = TwcsPicker {
1189            trigger_file_num: 4, // High enough to prevent runs in first window
1190            time_window_seconds: Some(3),
1191            max_output_file_size: None,
1192            append_mode: false,
1193            max_background_tasks: None,
1194            time_range: None,
1195        };
1196
1197        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1198        let output = picker
1199            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
1200            .await
1201            .unwrap();
1202
1203        assert!(
1204            !output.is_empty(),
1205            "Should have output from windows with runs, even when one window has 0 runs"
1206        );
1207
1208        let all_output_files: Vec<_> = output
1209            .iter()
1210            .flat_map(|o| o.inputs.iter())
1211            .map(|f| f.file_id().file_id())
1212            .collect();
1213
1214        assert!(
1215            all_output_files.contains(&file_ids[3])
1216                || all_output_files.contains(&file_ids[4])
1217                || all_output_files.contains(&file_ids[5]),
1218            "Output should contain files from the window with runs"
1219        );
1220    }
1221
1222    #[tokio::test]
1223    async fn test_build_output_single_window_zero_runs() {
1224        let file_ids = (0..2).map(|_| FileId::random()).collect::<Vec<_>>();
1225
1226        let large_file_1 = new_file_handle_with_size_and_sequence(file_ids[0], 0, 999, 0, 1, 2000); // 2000 bytes
1227        let large_file_2 = new_file_handle_with_size_and_sequence(file_ids[1], 0, 999, 0, 2, 2500); // 2500 bytes
1228
1229        let files = [large_file_1, large_file_2];
1230
1231        let windows = assign_to_windows(files.iter(), 3);
1232
1233        let picker = TwcsPicker {
1234            trigger_file_num: 2,
1235            time_window_seconds: Some(3),
1236            max_output_file_size: Some(1000),
1237            append_mode: true,
1238            max_background_tasks: None,
1239            time_range: None,
1240        };
1241
1242        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1243        let output = picker
1244            .build_output_with_time_range(RegionId::from_u64(456), windows, active_window, None)
1245            .await
1246            .unwrap();
1247
1248        // Should return empty output (no compaction needed)
1249        assert!(
1250            output.is_empty(),
1251            "Should return empty output when no runs are found after filtering"
1252        );
1253    }
1254
1255    #[tokio::test]
1256    async fn test_max_background_tasks_truncation() {
1257        let file_ids = (0..10).map(|_| FileId::random()).collect::<Vec<_>>();
1258        let max_background_tasks = 3;
1259
1260        // Create files across multiple windows that will generate multiple compaction outputs
1261        let files = [
1262            // Window 0: 4 files that will form a run
1263            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
1264            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
1265            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
1266            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
1267            // Window 3: 4 files that will form another run
1268            new_file_handle_with_sequence(file_ids[4], 3000, 3999, 0, 5),
1269            new_file_handle_with_sequence(file_ids[5], 3000, 3999, 0, 6),
1270            new_file_handle_with_sequence(file_ids[6], 3000, 3999, 0, 7),
1271            new_file_handle_with_sequence(file_ids[7], 3000, 3999, 0, 8),
1272            // Window 6: 4 files that will form another run
1273            new_file_handle_with_sequence(file_ids[8], 6000, 6999, 0, 9),
1274            new_file_handle_with_sequence(file_ids[9], 6000, 6999, 0, 10),
1275        ];
1276
1277        let windows = assign_to_windows(files.iter(), 3);
1278
1279        let picker = TwcsPicker {
1280            trigger_file_num: 4,
1281            time_window_seconds: Some(3),
1282            max_output_file_size: None,
1283            append_mode: false,
1284            max_background_tasks: Some(max_background_tasks),
1285            time_range: None,
1286        };
1287
1288        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1289        let output = picker
1290            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
1291            .await
1292            .unwrap();
1293
1294        // Should have at most max_background_tasks outputs
1295        assert!(
1296            output.len() <= max_background_tasks,
1297            "Output should be truncated to max_background_tasks: expected <= {}, got {}",
1298            max_background_tasks,
1299            output.len()
1300        );
1301
1302        // Without max_background_tasks, should have more outputs
1303        let picker_no_limit = TwcsPicker {
1304            trigger_file_num: 4,
1305            time_window_seconds: Some(3),
1306            max_output_file_size: None,
1307            append_mode: false,
1308            max_background_tasks: None,
1309            time_range: None,
1310        };
1311
1312        let windows_no_limit = assign_to_windows(files.iter(), 3);
1313        let output_no_limit = picker_no_limit
1314            .build_output_with_time_range(
1315                RegionId::from_u64(123),
1316                windows_no_limit,
1317                active_window,
1318                None,
1319            )
1320            .await
1321            .unwrap();
1322
1323        // Without limit, should have more outputs (if there are enough windows)
1324        if output_no_limit.len() > max_background_tasks {
1325            assert!(
1326                output_no_limit.len() > output.len(),
1327                "Without limit should have more outputs than with limit"
1328            );
1329        }
1330    }
1331
1332    #[tokio::test]
1333    async fn test_max_background_tasks_no_truncation_when_under_limit() {
1334        let file_ids = (0..4).map(|_| FileId::random()).collect::<Vec<_>>();
1335        let max_background_tasks = 10; // Larger than expected outputs
1336
1337        // Create files in one window that will generate one compaction output
1338        let files = [
1339            new_file_handle_with_sequence(file_ids[0], 0, 999, 0, 1),
1340            new_file_handle_with_sequence(file_ids[1], 0, 999, 0, 2),
1341            new_file_handle_with_sequence(file_ids[2], 0, 999, 0, 3),
1342            new_file_handle_with_sequence(file_ids[3], 0, 999, 0, 4),
1343        ];
1344
1345        let windows = assign_to_windows(files.iter(), 3);
1346
1347        let picker = TwcsPicker {
1348            trigger_file_num: 4,
1349            time_window_seconds: Some(3),
1350            max_output_file_size: None,
1351            append_mode: false,
1352            max_background_tasks: Some(max_background_tasks),
1353            time_range: None,
1354        };
1355
1356        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1357        let output = picker
1358            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
1359            .await
1360            .unwrap();
1361
1362        // Should have all outputs since we're under the limit
1363        assert!(
1364            output.len() <= max_background_tasks,
1365            "Output should be within limit"
1366        );
1367        // Should have at least one output
1368        assert!(!output.is_empty(), "Should have at least one output");
1369    }
1370
1371    #[tokio::test]
1372    async fn test_pick_multiple_runs() {
1373        common_telemetry::init_default_ut_logging();
1374
1375        let num_files = 8;
1376        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
1377
1378        // Create files with different sequences so they form multiple runs
1379        let files: Vec<_> = file_ids
1380            .iter()
1381            .enumerate()
1382            .map(|(idx, file_id)| {
1383                new_file_handle_with_size_and_sequence(
1384                    *file_id,
1385                    0,
1386                    999,
1387                    0,
1388                    (idx + 1) as u64,
1389                    1024 * 1024,
1390                )
1391            })
1392            .collect();
1393
1394        let windows = assign_to_windows(files.iter(), 3);
1395
1396        let picker = TwcsPicker {
1397            trigger_file_num: 4,
1398            time_window_seconds: Some(3),
1399            max_output_file_size: None,
1400            append_mode: false,
1401            max_background_tasks: None,
1402            time_range: None,
1403        };
1404
1405        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1406        let output = picker
1407            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
1408            .await
1409            .unwrap();
1410
1411        assert_eq!(1, output.len());
1412        assert_eq!(output[0].inputs.len(), 2);
1413    }
1414
1415    #[tokio::test]
1416    async fn test_limit_max_input_files() {
1417        common_telemetry::init_default_ut_logging();
1418
1419        let num_files = 50;
1420        let file_ids = (0..num_files).map(|_| FileId::random()).collect::<Vec<_>>();
1421
1422        // Create files with different sequences so they form 2 runs
1423        let files: Vec<_> = file_ids
1424            .iter()
1425            .enumerate()
1426            .map(|(idx, file_id)| {
1427                new_file_handle_with_size_and_sequence(
1428                    *file_id,
1429                    (idx / 2 * 10) as i64,
1430                    (idx / 2 * 10 + 5) as i64,
1431                    0,
1432                    (idx + 1) as u64,
1433                    1024 * 1024,
1434                )
1435            })
1436            .collect();
1437
1438        let windows = assign_to_windows(files.iter(), 3);
1439
1440        let picker = TwcsPicker {
1441            trigger_file_num: 4,
1442            time_window_seconds: Some(3),
1443            max_output_file_size: None,
1444            append_mode: false,
1445            max_background_tasks: None,
1446            time_range: None,
1447        };
1448
1449        let active_window = find_latest_window_in_seconds(files.iter(), 3);
1450        let output = picker
1451            .build_output_with_time_range(RegionId::from_u64(123), windows, active_window, None)
1452            .await
1453            .unwrap();
1454
1455        assert_eq!(1, output.len());
1456        assert_eq!(output[0].inputs.len(), 32);
1457    }
1458
1459    #[tokio::test]
1460    async fn test_newer_windows_have_priority() {
1461        let older_file_ids = [FileId::random(), FileId::random()];
1462        let newer_file_ids = [FileId::random(), FileId::random()];
1463        let files = [
1464            new_file_handle_with_sequence(older_file_ids[0], 1_000, 1_999, 0, 1),
1465            new_file_handle_with_sequence(older_file_ids[1], 1_000, 1_999, 0, 2),
1466            new_file_handle_with_sequence(newer_file_ids[0], 7_000, 7_999, 0, 3),
1467            new_file_handle_with_sequence(newer_file_ids[1], 7_000, 7_999, 0, 4),
1468        ];
1469        let windows = assign_to_windows(files.iter(), 3);
1470        let picker = TwcsPicker {
1471            trigger_file_num: 2,
1472            time_window_seconds: Some(3),
1473            max_output_file_size: None,
1474            append_mode: false,
1475            max_background_tasks: Some(1),
1476            time_range: None,
1477        };
1478
1479        let output = picker
1480            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(9), None)
1481            .await
1482            .unwrap();
1483
1484        assert_eq!(1, output.len());
1485        assert_eq!(
1486            newer_file_ids.into_iter().collect::<HashSet<_>>(),
1487            output[0]
1488                .inputs
1489                .iter()
1490                .map(|file| file.file_id().file_id())
1491                .collect::<HashSet<_>>()
1492        );
1493    }
1494
1495    #[test]
1496    fn test_filter_time_windows_by_time_range() {
1497        let time_range = TimestampRange::new(
1498            Timestamp::new_millisecond(1_200),
1499            Timestamp::new_millisecond(1_800),
1500        )
1501        .unwrap();
1502
1503        assert!(time_window_intersects_range(3, 3, &time_range));
1504        assert!(!time_window_intersects_range(9, 3, &time_range));
1505
1506        let boundary_range =
1507            TimestampRange::new(Timestamp::new_second(0), Timestamp::new_second(3)).unwrap();
1508        assert!(time_window_intersects_range(0, 3, &boundary_range));
1509        assert!(time_window_intersects_range(3, 3, &boundary_range));
1510        assert!(!time_window_intersects_range(6, 3, &boundary_range));
1511
1512        let overflowing_range = TimestampRange::new(
1513            Timestamp::new_second(i64::MAX - 1),
1514            Timestamp::new_second(i64::MAX),
1515        )
1516        .unwrap();
1517        assert!(!time_window_intersects_range(0, 4, &overflowing_range));
1518    }
1519
1520    #[tokio::test]
1521    async fn test_time_range_filter_precedes_background_task_limit() {
1522        let early_file_ids = [FileId::random(), FileId::random()];
1523        let selected_file_ids = [FileId::random(), FileId::random()];
1524        let files = [
1525            new_file_handle_with_sequence(early_file_ids[0], 1_000, 1_999, 0, 1),
1526            new_file_handle_with_sequence(early_file_ids[1], 1_000, 1_999, 0, 2),
1527            new_file_handle_with_sequence(selected_file_ids[0], 7_000, 7_999, 0, 3),
1528            new_file_handle_with_sequence(selected_file_ids[1], 7_000, 7_999, 0, 4),
1529        ];
1530        let windows = assign_to_windows(files.iter(), 3);
1531        let picker = TwcsPicker {
1532            trigger_file_num: 2,
1533            time_window_seconds: Some(3),
1534            max_output_file_size: None,
1535            append_mode: false,
1536            max_background_tasks: Some(1),
1537            time_range: TimestampRange::new(
1538                Timestamp::new_millisecond(7_200),
1539                Timestamp::new_millisecond(7_800),
1540            ),
1541        };
1542
1543        let output = picker
1544            .build_output_with_time_range(RegionId::from_u64(123), windows, Some(9), Some(3))
1545            .await
1546            .unwrap();
1547
1548        assert_eq!(1, output.len());
1549        assert_eq!(
1550            selected_file_ids.into_iter().collect::<HashSet<_>>(),
1551            output[0]
1552                .inputs
1553                .iter()
1554                .map(|file| file.file_id().file_id())
1555                .collect::<HashSet<_>>()
1556        );
1557    }
1558
1559    // TODO(hl): TTL tester that checks if get_expired_ssts function works as expected.
1560}