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