1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
16use std::fmt::Debug;
17
18use common_telemetry::info;
19use common_time::Timestamp;
20use common_time::range::TimestampRange;
21use common_time::timestamp::TimeUnit;
22use common_time::timestamp_millis::BucketAligned;
23use snafu::ResultExt;
24use store_api::storage::RegionId;
25
26use crate::compaction::CompactionOutput;
27use crate::compaction::buckets::infer_time_bucket;
28use crate::compaction::compactor::{CompactionRegion, CompactionVersion};
29use crate::compaction::last_non_null::inputs_precede_memtables;
30use crate::compaction::picker::{Picker, PickerOutput, get_expired_ssts};
31use crate::error::{JoinSnafu, Result};
32use crate::region::options::{CompactionOptions, MergeMode};
33use crate::sst::file::FileHandle;
34
35#[derive(Clone, Debug)]
39pub struct WindowedCompactionPicker {
40 compaction_time_window_seconds: Option<i64>,
41 time_range: Option<TimestampRange>,
42}
43
44impl WindowedCompactionPicker {
45 pub fn new(window_seconds: Option<i64>) -> Self {
46 Self {
47 compaction_time_window_seconds: window_seconds,
48 time_range: None,
49 }
50 }
51
52 pub(crate) fn with_time_range(mut self, time_range: Option<TimestampRange>) -> Self {
54 self.time_range = time_range;
55 self
56 }
57
58 fn calculate_time_window(
63 &self,
64 region_id: RegionId,
65 current_version: &CompactionVersion,
66 ) -> i64 {
67 self.compaction_time_window_seconds
68 .or(current_version
69 .compaction_time_window
70 .map(|t| t.as_secs() as i64))
71 .unwrap_or_else(|| {
72 let levels = current_version.ssts.levels();
73 let inferred = infer_time_bucket(levels[0].files());
74 info!(
75 "Compaction window for region {} is not present, inferring from files: {:?}",
76 region_id, inferred
77 );
78 inferred
79 })
80 }
81
82 fn pick_inner(
83 &self,
84 region_id: RegionId,
85 current_version: &CompactionVersion,
86 current_time: Timestamp,
87 ) -> (Vec<CompactionOutput>, Vec<FileHandle>, i64) {
88 let time_window = self.calculate_time_window(region_id, current_version);
89 info!(
90 "Compaction window for region: {} is {} seconds",
91 region_id, time_window
92 );
93
94 let expired_ssts = get_expired_ssts(
95 current_version.ssts.levels(),
96 current_version.options.ttl,
97 current_time,
98 );
99 if !expired_ssts.is_empty() {
100 info!("Expired SSTs in region {}: {:?}", region_id, expired_ssts);
101 }
102 let expired_file_ids = expired_ssts
103 .iter()
104 .map(|file| file.file_id())
105 .collect::<HashSet<_>>();
106
107 let last_non_null = current_version.options.merge_mode() == MergeMode::LastNonNull;
108 let windows = assign_files_to_time_windows(
111 time_window,
112 current_version
113 .ssts
114 .levels()
115 .iter()
116 .flat_map(|level| level.files.values())
117 .filter(|file| !expired_file_ids.contains(&file.file_id()))
118 .filter(|file| last_non_null || !file.compacting()),
119 );
120 let windows = filter_time_windows(windows, self.time_range);
121
122 if last_non_null
125 && windows.values().any(|(_, inputs)| {
126 inputs.iter().any(FileHandle::compacting)
127 || !inputs_precede_memtables(inputs, current_version.memtable_min_sequence)
128 })
129 {
130 return (vec![], expired_ssts, time_window);
131 }
132
133 (build_output(windows), expired_ssts, time_window)
134 }
135}
136
137#[async_trait::async_trait]
138impl Picker for WindowedCompactionPicker {
139 async fn pick(&self, compaction_region: &CompactionRegion) -> Result<Option<PickerOutput>> {
140 let picker = self.clone();
146 let region_id = compaction_region.current_version.metadata.region_id;
147 let current_version = compaction_region.current_version.clone();
148 let CompactionOptions::Twcs(options) = &compaction_region.region_options.compaction;
149 let max_file_size = options
151 .max_output_file_size
152 .map(|size| size.as_bytes())
153 .filter(|size| *size > 0)
154 .map(|size| size as usize);
155 let (outputs, expired_ssts, time_window) =
156 common_runtime::spawn_blocking_compact(move || {
157 picker.pick_inner(region_id, ¤t_version, Timestamp::current_millis())
158 })
159 .await
160 .context(JoinSnafu)?;
161
162 Ok(Some(PickerOutput {
163 outputs,
164 expired_ssts,
165 time_window_size: time_window,
166 max_file_size,
167 }))
168 }
169}
170
171fn filter_time_windows(
179 mut windows: BTreeMap<i64, (i64, Vec<FileHandle>)>,
180 time_range: Option<TimestampRange>,
181) -> BTreeMap<i64, (i64, Vec<FileHandle>)> {
182 let Some(time_range) = time_range else {
183 return windows;
184 };
185
186 let mut selected_windows = windows
187 .iter()
188 .filter_map(|(lower_bound, (upper_bound, _))| {
189 let window_start = Timestamp::new_second(*lower_bound);
190 let window_end = Timestamp::new_second(*upper_bound);
191 let starts_before_range_end = time_range
192 .end()
193 .is_none_or(|range_end| window_start < range_end);
194 let ends_after_range_start = time_range
195 .start()
196 .is_none_or(|range_start| range_start < window_end);
197 (starts_before_range_end && ends_after_range_start).then_some(*lower_bound)
198 })
199 .collect::<HashSet<_>>();
200
201 let mut file_windows = HashMap::new();
202 for (lower_bound, (_, files)) in &windows {
203 for file in files {
204 file_windows
205 .entry(file.file_id())
206 .or_insert_with(Vec::new)
207 .push(*lower_bound);
208 }
209 }
210
211 let mut pending_windows = selected_windows.iter().copied().collect::<VecDeque<_>>();
212 let mut visited_files = HashSet::new();
213 while let Some(lower_bound) = pending_windows.pop_front() {
214 let (_, files) = &windows[&lower_bound];
215 for file in files {
216 if !visited_files.insert(file.file_id()) {
217 continue;
218 }
219 for dependent_window in &file_windows[&file.file_id()] {
220 if selected_windows.insert(*dependent_window) {
221 pending_windows.push_back(*dependent_window);
222 }
223 }
224 }
225 }
226
227 windows.retain(|lower_bound, _| selected_windows.contains(lower_bound));
228 windows
229}
230
231fn build_output(windows: BTreeMap<i64, (i64, Vec<FileHandle>)>) -> Vec<CompactionOutput> {
232 let mut outputs = Vec::with_capacity(windows.len());
233 for (lower_bound, (upper_bound, files)) in windows {
234 let output_time_range = Some(
236 TimestampRange::new(
237 Timestamp::new_second(lower_bound),
238 Timestamp::new_second(upper_bound),
239 )
240 .unwrap(),
241 );
242
243 let output = CompactionOutput {
244 output_level: 1,
245 inputs: files,
246 filter_deleted: false,
247 output_time_range,
248 };
249 outputs.push(output);
250 }
251
252 outputs
253}
254
255fn assign_files_to_time_windows<'a>(
259 bucket_sec: i64,
260 files: impl Iterator<Item = &'a FileHandle>,
261) -> BTreeMap<i64, (i64, Vec<FileHandle>)> {
262 let mut buckets = BTreeMap::new();
263
264 for file in files {
265 let (start, end) = file.time_range();
266 let bounds = file_time_bucket_span(
267 start.convert_to(TimeUnit::Second).unwrap().value(),
269 end.convert_to(TimeUnit::Second).unwrap().value(),
270 bucket_sec,
271 );
272 for (lower_bound, upper_bound) in bounds {
273 let (_, files) = buckets
274 .entry(lower_bound)
275 .or_insert_with(|| (upper_bound, Vec::new()));
276 files.push(file.clone());
277 }
278 }
279 buckets
280}
281
282fn file_time_bucket_span(start_sec: i64, end_sec: i64, bucket_sec: i64) -> Vec<(i64, i64)> {
284 assert!(start_sec <= end_sec);
285
286 let mut start_aligned = start_sec.align_by_bucket(bucket_sec).unwrap_or(i64::MIN);
289 let end_aligned = end_sec
290 .align_by_bucket(bucket_sec)
291 .unwrap_or(start_aligned + (end_sec - start_sec));
292
293 let mut res = Vec::with_capacity(((end_aligned - start_aligned) / bucket_sec + 1) as usize);
294 while start_aligned <= end_aligned {
295 let window_size = if start_aligned % bucket_sec == 0 {
296 bucket_sec
297 } else {
298 (start_aligned % bucket_sec).abs()
299 };
300 let upper_bound = start_aligned.checked_add(window_size).unwrap_or(i64::MAX);
301 res.push((start_aligned, upper_bound));
302 start_aligned = upper_bound;
303 }
304 res
305}
306
307#[cfg(test)]
308mod tests {
309 use std::collections::HashSet;
310 use std::sync::Arc;
311 use std::time::Duration;
312
313 use common_base::readable_size::ReadableSize;
314 use common_time::Timestamp;
315 use common_time::range::TimestampRange;
316 use store_api::storage::{FileId, RegionId};
317
318 use crate::compaction::compactor::CompactionVersion;
319 use crate::compaction::picker::Picker;
320 use crate::compaction::test_util::compaction_region_with_ssts;
321 use crate::compaction::window::{WindowedCompactionPicker, file_time_bucket_span};
322 use crate::region::options::{CompactionOptions, MergeMode, RegionOptions};
323 use crate::sst::file::{FileMeta, Level};
324 use crate::sst::file_purger::NoopFilePurger;
325 use crate::sst::version::SstVersion;
326 use crate::test_util::memtable_util::metadata_for_test;
327
328 fn build_version(
329 files: &[(FileId, i64, i64, Level)],
330 ttl: Option<Duration>,
331 ) -> CompactionVersion {
332 let metadata = metadata_for_test();
333 let file_purger_ref = Arc::new(NoopFilePurger);
334
335 let mut ssts = SstVersion::new(metadata.clone());
336
337 ssts.add_files(
338 file_purger_ref,
339 files.iter().map(|(file_id, start, end, level)| FileMeta {
340 file_id: *file_id,
341 time_range: (
342 Timestamp::new_millisecond(*start),
343 Timestamp::new_millisecond(*end),
344 ),
345 level: *level,
346 ..Default::default()
347 }),
348 );
349
350 CompactionVersion {
351 metadata,
352 ssts: Arc::new(ssts),
353 memtable_min_sequence: None,
354 options: RegionOptions {
355 ttl: ttl.map(|t| t.into()),
356 auto_flush_interval: None,
357 compaction: Default::default(),
358 compaction_override: false,
359 compaction_output_parallelism: None,
360 storage: None,
361 append_mode: false,
362 skip_wal: false,
363 wal_options: Default::default(),
364 index_options: Default::default(),
365 memtable: None,
366 merge_mode: None,
367 sst_format: None,
368 max_row_group_row_count: None,
369 primary_key_encoding: None,
370 write_buffer_size: None,
371 preserve_row_sequence: false,
372 float_field_encoding: Default::default(),
373 },
374 compaction_time_window: None,
375 }
376 }
377
378 #[tokio::test]
379 async fn test_pick_output_file_size_threshold() {
380 let mut region = compaction_region_with_ssts(
381 [FileMeta {
382 file_id: FileId::random(),
383 time_range: (Timestamp::new_second(0), Timestamp::new_second(1)),
384 ..Default::default()
385 }],
386 Duration::from_secs(3600),
387 )
388 .await;
389 let picker = WindowedCompactionPicker::new(Some(3600));
390
391 for (size, expected) in [(None, None), (Some(0), None), (Some(1024), Some(1024))] {
392 let CompactionOptions::Twcs(options) = &mut region.region_options.compaction;
393 options.max_output_file_size = size.map(ReadableSize);
394
395 let output = picker.pick(®ion).await.unwrap().unwrap();
396 assert_eq!(1, output.outputs.len());
397 assert_eq!(expected, output.max_file_size);
398 }
399 }
400
401 #[test]
402 fn test_pick_expired_ssts_without_marking_compacting() {
403 let picker = WindowedCompactionPicker::new(None);
404 let files = vec![(FileId::random(), 0, 10, 0)];
405 let version = build_version(&files, Some(Duration::from_millis(1)));
406 let (outputs, expired_ssts, _) = picker.pick_inner(
407 RegionId::new(0, 0),
408 &version,
409 Timestamp::new_millisecond(12),
410 );
411
412 assert!(outputs.is_empty());
413 assert_eq!(1, expired_ssts.len());
414 assert!(expired_ssts.iter().all(|file| !file.compacting()));
415 }
416
417 const HOUR: i64 = 60 * 60 * 1000;
418
419 #[test]
420 fn test_infer_window() {
421 let picker = WindowedCompactionPicker::new(None);
422
423 let files = vec![
424 (FileId::random(), 0, HOUR, 0),
425 (FileId::random(), HOUR, HOUR * 2 - 1, 0),
426 ];
427
428 let version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
429
430 let (outputs, expired_ssts, window_seconds) = picker.pick_inner(
431 RegionId::new(0, 0),
432 &version,
433 Timestamp::new_millisecond(HOUR * 2),
434 );
435 assert!(expired_ssts.is_empty());
436 assert_eq!(2 * HOUR / 1000, window_seconds);
437 assert_eq!(1, outputs.len());
438 assert_eq!(2, outputs[0].inputs.len());
439 }
440
441 #[test]
442 fn test_assign_files_to_windows() {
443 let picker = WindowedCompactionPicker::new(Some(HOUR / 1000));
444 let files = vec![
445 (FileId::random(), 0, 2 * HOUR - 1, 0),
446 (FileId::random(), HOUR, HOUR * 3 - 1, 0),
447 ];
448 let version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
449 let (outputs, expired_ssts, window_seconds) = picker.pick_inner(
450 RegionId::new(0, 0),
451 &version,
452 Timestamp::new_millisecond(HOUR * 3),
453 );
454
455 assert!(expired_ssts.is_empty());
456 assert_eq!(HOUR / 1000, window_seconds);
457 assert_eq!(3, outputs.len());
458
459 assert_eq!(1, outputs[0].inputs.len());
460 assert_eq!(files[0].0, outputs[0].inputs[0].file_id().file_id());
461 assert_eq!(
462 TimestampRange::new(
463 Timestamp::new_millisecond(0),
464 Timestamp::new_millisecond(HOUR)
465 ),
466 outputs[0].output_time_range
467 );
468
469 assert_eq!(2, outputs[1].inputs.len());
470 assert_eq!(
471 TimestampRange::new(
472 Timestamp::new_millisecond(HOUR),
473 Timestamp::new_millisecond(2 * HOUR)
474 ),
475 outputs[1].output_time_range
476 );
477
478 assert_eq!(1, outputs[2].inputs.len());
479 assert_eq!(files[1].0, outputs[2].inputs[0].file_id().file_id());
480 assert_eq!(
481 TimestampRange::new(
482 Timestamp::new_millisecond(2 * HOUR),
483 Timestamp::new_millisecond(3 * HOUR)
484 ),
485 outputs[2].output_time_range
486 );
487 }
488
489 #[test]
490 fn test_pick_time_range_expands_for_cross_window_files() {
491 let time_range = TimestampRange::new(
492 Timestamp::new_millisecond(HOUR / 2),
493 Timestamp::new_millisecond(HOUR * 3 / 4),
494 )
495 .unwrap();
496 let picker =
497 WindowedCompactionPicker::new(Some(HOUR / 1000)).with_time_range(Some(time_range));
498 let files = vec![
499 (FileId::random(), 0, 2 * HOUR - 1, 0),
500 (FileId::random(), HOUR, HOUR * 3 - 1, 0),
501 (FileId::random(), 4 * HOUR, 5 * HOUR - 1, 0),
502 ];
503 let version = build_version(&files, None);
504
505 let (outputs, _, _) = picker.pick_inner(
506 RegionId::new(0, 0),
507 &version,
508 Timestamp::new_millisecond(6 * HOUR),
509 );
510
511 assert_eq!(3, outputs.len());
512 assert_eq!(
513 Some(TimestampRange::new(
514 Timestamp::new_millisecond(0),
515 Timestamp::new_millisecond(HOUR),
516 )),
517 outputs.first().map(|output| output.output_time_range)
518 );
519 assert_eq!(
520 Some(TimestampRange::new(
521 Timestamp::new_millisecond(2 * HOUR),
522 Timestamp::new_millisecond(3 * HOUR),
523 )),
524 outputs.last().map(|output| output.output_time_range)
525 );
526 }
527
528 #[test]
529 fn test_pick_time_range_expands_long_dependency_chain() {
530 const CHAIN_LEN: i64 = 128;
531
532 let time_range = TimestampRange::new(
533 Timestamp::new_millisecond(0),
534 Timestamp::new_millisecond(HOUR / 2),
535 )
536 .unwrap();
537 let picker =
538 WindowedCompactionPicker::new(Some(HOUR / 1000)).with_time_range(Some(time_range));
539 let files = (0..CHAIN_LEN)
540 .map(|window| (FileId::random(), window * HOUR, (window + 2) * HOUR - 1, 0))
541 .collect::<Vec<_>>();
542 let version = build_version(&files, None);
543
544 let (outputs, _, _) = picker.pick_inner(
545 RegionId::new(0, 0),
546 &version,
547 Timestamp::new_millisecond((CHAIN_LEN + 2) * HOUR),
548 );
549
550 assert_eq!(CHAIN_LEN as usize + 1, outputs.len());
551 }
552
553 #[test]
554 fn test_assign_compacting_files_to_windows() {
555 let picker = WindowedCompactionPicker::new(Some(HOUR / 1000));
556 let files = vec![
557 (FileId::random(), 0, 2 * HOUR - 1, 0),
558 (FileId::random(), HOUR, HOUR * 3 - 1, 0),
559 ];
560 let version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
561 version.ssts.levels()[0]
562 .files()
563 .for_each(|f| f.set_compacting(true));
564 let (outputs, expired_ssts, window_seconds) = picker.pick_inner(
565 RegionId::new(0, 0),
566 &version,
567 Timestamp::new_millisecond(HOUR * 3),
568 );
569
570 assert!(expired_ssts.is_empty());
571 assert_eq!(HOUR / 1000, window_seconds);
572 assert!(outputs.is_empty());
573 }
574
575 #[rstest::rstest]
576 #[case::requested_window(0)]
577 #[case::transitive_window(1)]
578 #[case::unrelated_window(3)]
579 fn test_busy_dependencies_defer_last_non_null_plan_until_released(
580 #[case] busy_window: i64,
581 #[values(MergeMode::LastRow, MergeMode::LastNonNull)] merge_mode: MergeMode,
582 ) {
583 let [a, b, c, expired] = std::array::from_fn(|_| FileId::random());
587 let files = [
588 (a, 0, 2 * HOUR - 1, 0),
589 (b, busy_window * HOUR, (busy_window + 1) * HOUR - 1, 1),
590 (c, 0, 2 * HOUR - 1, 0),
591 (expired, -2 * HOUR, -2 * HOUR, 0),
592 ];
593 let mut version = build_version(&files, Some(Duration::from_millis(3 * HOUR as u64)));
594 version.options.merge_mode = Some(merge_mode);
595 assert_eq!(None, version.memtable_min_sequence);
597 let busy_file = version.ssts.levels()[1].files().next().unwrap();
598 let picker =
599 WindowedCompactionPicker::new(Some(HOUR / 1000)).with_time_range(TimestampRange::new(
600 Timestamp::new_millisecond(0),
601 Timestamp::new_millisecond(HOUR),
602 ));
603
604 for busy in [true, false] {
605 busy_file.set_compacting(busy);
606 let (outputs, expired_ssts, _) = picker.pick_inner(
607 version.metadata.region_id,
608 &version,
609 Timestamp::new_millisecond(2 * HOUR),
610 );
611 assert_eq!(1, expired_ssts.len());
612 assert_eq!(expired, expired_ssts[0].meta_ref().file_id);
613 assert!(!expired_ssts[0].compacting());
614
615 if busy && merge_mode == MergeMode::LastNonNull && busy_window < 2 {
616 assert!(outputs.is_empty(), "the entire dependent plan must wait");
617 continue;
618 }
619
620 assert_eq!(2, outputs.len());
621 for (window, output) in outputs.iter().enumerate() {
622 let window = window as i64;
623 let mut expected = HashSet::from([a, c]);
624 if !busy && busy_window == window {
625 expected.insert(b);
626 }
627 let actual: HashSet<_> = output
628 .inputs
629 .iter()
630 .map(|file| file.meta_ref().file_id)
631 .collect();
632 assert_eq!(expected, actual);
633 assert_eq!(
634 TimestampRange::new(
635 Timestamp::new_millisecond(window * HOUR),
636 Timestamp::new_millisecond((window + 1) * HOUR),
637 ),
638 output.output_time_range
639 );
640 }
641 }
642 }
643
644 #[test]
645 fn test_file_time_bucket_span() {
646 assert_eq!(
647 vec![(i64::MIN, i64::MIN + 8),],
648 file_time_bucket_span(i64::MIN, i64::MIN + 1, 10)
649 );
650
651 assert_eq!(
652 vec![(i64::MIN, i64::MIN + 8), (i64::MIN + 8, i64::MIN + 18)],
653 file_time_bucket_span(i64::MIN, i64::MIN + 8, 10)
654 );
655
656 assert_eq!(
657 vec![
658 (i64::MIN, i64::MIN + 8),
659 (i64::MIN + 8, i64::MIN + 18),
660 (i64::MIN + 18, i64::MIN + 28)
661 ],
662 file_time_bucket_span(i64::MIN, i64::MIN + 20, 10)
663 );
664
665 assert_eq!(
666 vec![(-10, 0), (0, 10), (10, 20)],
667 file_time_bucket_span(-1, 11, 10)
668 );
669
670 assert_eq!(
671 vec![(-3, 0), (0, 3), (3, 6)],
672 file_time_bucket_span(-1, 3, 3)
673 );
674
675 assert_eq!(vec![(0, 10)], file_time_bucket_span(0, 9, 10));
676
677 assert_eq!(
678 vec![(i64::MAX - (i64::MAX % 10), i64::MAX)],
679 file_time_bucket_span(i64::MAX - 1, i64::MAX, 10)
680 );
681 }
682}