Skip to main content

mito2/compaction/
last_non_null.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{HashMap, HashSet};
16
17use snafu::ResultExt;
18use store_api::metadata::RegionMetadataRef;
19use store_api::storage::SequenceNumber;
20
21use crate::compaction::CompactionOutput;
22use crate::compaction::compactor::CompactionRegion;
23use crate::compaction::overlap::FileOverlapIndex;
24use crate::compaction::picker::{Picker, PickerOutput};
25use crate::compaction::twcs::TwcsPicker;
26use crate::error::{JoinSnafu, Result};
27use crate::sst::file::{FileHandle, RegionFileId};
28
29/// Picker for [`MergeMode::LastNonNull`](crate::region::options::MergeMode).
30///
31/// LastNonNull fills null fields of a newer row from older non-null values when
32/// rows with the same key and timestamp are merged. That fill is only correct if
33/// every SST that can contribute to the same logical row is merged together:
34/// once a value is promoted into an output, an intermediate version left behind
35/// in a separately compacted SST is invisible, so it can no longer override the
36/// promoted value on a later read.
37///
38/// TWCS alone cannot guarantee this: it schedules independent windows and seeds,
39/// while an L1 or cross-window SST can overlap several of them. This picker
40/// therefore delegates seed selection to [TwcsPicker] and then closes each seed
41/// over the whole snapshot: it absorbs every overlapping file and every seed
42/// connected through those overlaps, so a merge stream is never split across
43/// outputs, and defers a closure whose inputs are already compacting.
44#[derive(Debug)]
45pub(super) struct LastNonNullPicker {
46    base_picker: TwcsPicker,
47}
48
49impl LastNonNullPicker {
50    pub(super) fn new(base_picker: TwcsPicker) -> Self {
51        Self { base_picker }
52    }
53}
54
55#[async_trait::async_trait]
56impl Picker for LastNonNullPicker {
57    async fn pick(&self, region: &CompactionRegion) -> Result<Option<PickerOutput>> {
58        let max_outputs = self.base_picker.max_background_tasks;
59
60        // Find all possible compaction seeds
61        let Some(all_seeds) = self
62            .base_picker
63            .pick_with_output_limit(region, None)
64            .await?
65        else {
66            return Ok(None);
67        };
68        let (output, _) = close_outputs(region, all_seeds, max_outputs).await?;
69        Ok(output)
70    }
71}
72
73/// Attempts seeds in priority order until valid closures fill the output budget.
74/// Returns indices into the original seed list for all attempted/absorbed seeds,
75/// including rejected closures but excluding seeds left behind by the budget.
76pub(crate) async fn close_outputs(
77    region: &CompactionRegion,
78    mut all_seeds: PickerOutput,
79    max_outputs: Option<usize>,
80) -> Result<(Option<PickerOutput>, Vec<usize>)> {
81    let version = region.current_version.clone();
82    common_runtime::spawn_blocking_compact(move || {
83        let expired: HashSet<_> = all_seeds
84            .expired_ssts
85            .iter()
86            .map(FileHandle::file_id)
87            .collect();
88        // Include busy files and all levels/windows. Request ranges and
89        // TWCS size/file-count limits only constrain seeds, not correctness.
90        let all_files = version
91            .ssts
92            .levels()
93            .iter()
94            .flat_map(|level| level.files())
95            .filter(|file| !expired.contains(&file.file_id()))
96            .cloned()
97            .collect();
98        let (outputs, attempted_seeds) = build_closed_outputs(
99            all_seeds.outputs,
100            all_files,
101            &version.metadata,
102            ClosureLimits {
103                max_outputs,
104                memtable_min_sequence: version.memtable_min_sequence,
105            },
106        );
107        all_seeds.outputs = outputs;
108        let output = (!all_seeds.outputs.is_empty() || !all_seeds.expired_ssts.is_empty())
109            .then_some(all_seeds);
110        (output, attempted_seeds)
111    })
112    .await
113    .context(JoinSnafu)
114}
115
116/// Imported SSTs can be newer than pending writes. A merge must not attach an
117/// old field to a sequence above an intermediate version left in a memtable.
118pub(super) fn inputs_precede_memtables(
119    inputs: &[FileHandle],
120    memtable_min_sequence: Option<SequenceNumber>,
121) -> bool {
122    memtable_min_sequence.is_none_or(|min_sequence| {
123        inputs.iter().all(|file| {
124            file.meta_ref()
125                .sequence
126                .is_some_and(|max_sequence| max_sequence.get() < min_sequence)
127        })
128    })
129}
130
131/// Constraints checked on complete closures, never on partial seed inputs.
132#[derive(Default)]
133struct ClosureLimits {
134    max_outputs: Option<usize>,
135    memtable_min_sequence: Option<SequenceNumber>,
136}
137
138/// Each seed and snapshot file is consumed at most once. Absorbing a seed also
139/// absorbs its disconnected members: a merge stream must never be split across
140/// independently scheduled outputs.
141fn build_closed_outputs(
142    seeds: Vec<CompactionOutput>,
143    files: Vec<FileHandle>,
144    metadata: &RegionMetadataRef,
145    limits: ClosureLimits,
146) -> (Vec<CompactionOutput>, Vec<usize>) {
147    // Maps every seed input to its seed, so a closure that reaches that input
148    // can absorb the rest of the seed instead of letting it schedule separately.
149    let seed_by_file: HashMap<_, _> = seeds
150        .iter()
151        .enumerate()
152        .flat_map(|(i, seed)| seed.inputs.iter().map(move |file| (file.file_id(), i)))
153        .collect();
154    // `None` marks a seed already absorbed by an earlier closure.
155    let mut seeds: Vec<_> = seeds.into_iter().map(Some).collect();
156    let mut remaining = FileOverlapIndex::new(files, metadata);
157    let mut outputs = Vec::new();
158    // The compactor pops from the end; retain TWCS's seed priority.
159    for seed_index in (0..seeds.len()).rev() {
160        // Keep all seeds available for dependency absorption, but do not attempt
161        // lower-priority work after enough valid closures have filled the budget.
162        if outputs.len() >= limits.max_outputs.unwrap_or(usize::MAX) {
163            break;
164        }
165        let Some(output) = seeds[seed_index].take() else {
166            continue;
167        };
168        let output = expand_closure(output, &mut seeds, &seed_by_file, &mut remaining);
169        // Defer the entire closure rather than truncate it around busy files.
170        if !output.inputs.iter().any(FileHandle::compacting)
171            && inputs_precede_memtables(&output.inputs, limits.memtable_min_sequence)
172        {
173            outputs.push(output);
174        }
175    }
176    outputs.reverse();
177    let attempted_seeds = seeds
178        .iter()
179        .enumerate()
180        .filter_map(|(index, seed)| seed.is_none().then_some(index))
181        .collect();
182    (outputs, attempted_seeds)
183}
184
185/// Expands one seed into its full merge closure: absorb every connected seed
186/// and every overlapping snapshot file, transitively. Growing `output.inputs`
187/// while iterating is what makes the expansion transitive.
188fn expand_closure(
189    mut output: CompactionOutput,
190    seeds: &mut [Option<CompactionOutput>],
191    seed_by_file: &HashMap<RegionFileId, usize>,
192    remaining_files: &mut FileOverlapIndex<'_>,
193) -> CompactionOutput {
194    let mut selected = HashSet::new();
195    let inputs = std::mem::take(&mut output.inputs);
196    extend_inputs(&mut output, inputs, &mut selected, remaining_files);
197
198    let mut cursor = 0;
199    while cursor < output.inputs.len() {
200        let input = output.inputs[cursor].clone();
201        if let Some(&seed_index) = seed_by_file.get(&input.file_id())
202            && let Some(seed) = seeds[seed_index].take()
203        {
204            // AND the tombstone policies so the merged stream stays conservative.
205            output.filter_deleted &= seed.filter_deleted;
206            extend_inputs(&mut output, seed.inputs, &mut selected, remaining_files);
207        }
208        let dependencies = remaining_files.drain_overlaps(&input);
209        extend_inputs(&mut output, dependencies, &mut selected, remaining_files);
210        cursor += 1;
211    }
212    output
213}
214
215/// Appends never-seen inputs and removes them from the overlap index so the
216/// same file is neither expanded nor scheduled twice.
217fn extend_inputs(
218    output: &mut CompactionOutput,
219    inputs: Vec<FileHandle>,
220    selected: &mut HashSet<RegionFileId>,
221    remaining: &mut FileOverlapIndex<'_>,
222) {
223    for input in inputs {
224        if selected.insert(input.file_id()) {
225            remaining.remove(input.file_id());
226            output.inputs.push(input);
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use std::time::{Duration, Instant};
234
235    use api::v1::region::compact_request;
236    use common_time::Timestamp;
237    use common_time::range::TimestampRange;
238    use store_api::storage::FileId;
239
240    use super::*;
241    use crate::compaction::picker::new_picker;
242    use crate::compaction::scheduler::history::{PickHistory, PickKey};
243    use crate::compaction::scheduler::pick_automatic_for_test;
244    use crate::compaction::test_util::{
245        compaction_region_with_ssts, new_file_handle, new_file_handle_with_size_and_sequence,
246    };
247    use crate::compaction::twcs::PickClass;
248    use crate::region::options::{CompactionOptions, MergeMode};
249    use crate::sst::file::{FileMeta, RegionFileId};
250    use crate::test_util::memtable_util::metadata_for_test;
251
252    fn seed(inputs: Vec<FileHandle>) -> CompactionOutput {
253        CompactionOutput {
254            output_level: 1,
255            inputs,
256            filter_deleted: false,
257            output_time_range: None,
258        }
259    }
260
261    fn file_ids(files: &[FileHandle]) -> HashSet<RegionFileId> {
262        files.iter().map(FileHandle::file_id).collect()
263    }
264
265    #[rstest::rstest]
266    #[case::memtable_barrier(2, Some(20), false, Some(1), vec![vec![1]])]
267    #[case::busy_dependency(2, None, true, Some(1), vec![vec![1]])]
268    #[case::highest_priority(2, None, false, Some(1), vec![vec![2]])]
269    #[case::priority_order(2, None, false, Some(2), vec![vec![2], vec![1]])]
270    #[case::unlimited(2, None, false, None, vec![vec![2], vec![1], vec![0]])]
271    #[case::fewer_eligible_outputs(2, Some(20), false, Some(3), vec![vec![1], vec![0]])]
272    #[case::absorbed_seeds(1, None, false, Some(2), vec![vec![2, 1], vec![0]])]
273    #[case::busy_bridge_defers_both_seeds(1, None, true, Some(2), vec![vec![0]])]
274    #[case::memtable_barrier_defers_both_seeds(1, Some(20), false, Some(2), vec![vec![0]])]
275    #[case::all_closures_rejected(0, None, true, Some(1), vec![])]
276    #[case::zero_budget(2, None, false, Some(0), vec![])]
277    #[tokio::test]
278    async fn test_output_limit_counts_eligible_closures(
279        #[case] dependency_start_window: usize,
280        #[case] memtable_min: Option<u64>,
281        #[case] busy: bool,
282        #[case] limit: Option<usize>,
283        #[case] expected_windows: Vec<Vec<usize>>,
284        #[values(false, true)] fair: bool,
285    ) {
286        // Three independent, eligible L0 windows. A large L1 dependency is not
287        // a seed, but can reject the newest closure or connect two seeds.
288        // Rejecting that bridge must defer both connected seeds, leaving only
289        // the independent oldest window eligible rather than a partial merge.
290        let windows: Vec<Vec<_>> = (0..3)
291            .map(|window| {
292                (0..4)
293                    .map(|i| FileMeta {
294                        file_id: FileId::random(),
295                        time_range: (
296                            Timestamp::new_second(window * 3600),
297                            Timestamp::new_second(window * 3600 + 10),
298                        ),
299                        level: 0,
300                        file_size: 100,
301                        sequence: std::num::NonZeroU64::new((window * 4 + i + 1) as u64),
302                        ..Default::default()
303                    })
304                    .collect()
305            })
306            .collect();
307        let dependency = FileMeta {
308            file_id: FileId::random(),
309            time_range: (
310                Timestamp::new_second(dependency_start_window as i64 * 3600),
311                Timestamp::new_second(7210),
312            ),
313            level: 1,
314            file_size: 1_000_000,
315            sequence: std::num::NonZeroU64::new(20),
316            ..Default::default()
317        };
318        let dependency_id = dependency.file_id();
319        let files = windows.iter().flatten().cloned().chain([dependency]);
320        let mut region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
321        region.ttl = None;
322        region.region_options.merge_mode = Some(MergeMode::LastNonNull);
323        region.current_version.memtable_min_sequence = memtable_min;
324        let CompactionOptions::Twcs(opts) = &mut region.region_options.compaction;
325        opts.time_window = Some(Duration::from_secs(3600));
326        opts.active_window_trigger_file_num = 4;
327        opts.inactive_window_trigger_file_num = 4;
328        region.current_version.ssts.levels()[1]
329            .files()
330            .next()
331            .unwrap()
332            .set_compacting(busy);
333        let request = compact_request::Options::Regular(Default::default());
334        let picked = if fair {
335            let selected_metrics = prometheus::IntCounterVec::new(
336                prometheus::Opts::new("test_twcs_selected", "selected seeds"),
337                &["class", "priority"],
338            )
339            .unwrap();
340            let mut history = PickHistory::with_selected_metrics_for_test(selected_metrics.clone());
341            let now = Instant::now();
342            let waiting_since = now - Duration::from_secs(120);
343            let keys: Vec<_> = (0..3)
344                .map(|window| PickKey {
345                    window: window * 3600,
346                    class: PickClass::L0Merge,
347                })
348                .collect();
349            history.observe_for_test(keys.iter().copied(), waiting_since);
350            let promoted = selected_metrics.with_label_values(&["l0_merge", "promoted"]);
351            let promoted_before = promoted.get();
352            let picked = pick_automatic_for_test(&region, &mut history, limit)
353                .await
354                .unwrap();
355            let mut attempted_windows: HashSet<_> =
356                expected_windows.iter().flatten().copied().collect();
357            if busy || memtable_min.is_some() {
358                // A rejected closure consumes turns but no output slot, including
359                // any lower-priority seeds that it absorbed through the bridge.
360                attempted_windows.extend(dependency_start_window..3);
361            }
362            assert_eq!(
363                promoted_before + attempted_windows.len() as u64,
364                promoted.get()
365            );
366            for (window, key) in keys.into_iter().enumerate() {
367                assert_eq!(
368                    (!attempted_windows.contains(&window)).then_some(waiting_since),
369                    history.promoted_since_for_test(key, now),
370                    "window {window}"
371                );
372            }
373            picked
374        } else {
375            new_picker(&request, &region.region_options, limit, None, false)
376                .pick(&region)
377                .await
378                .unwrap()
379        };
380        let outputs = picked.map_or_else(Vec::new, |picked| {
381            assert!(picked.expired_ssts.is_empty());
382            picked.outputs
383        });
384        assert_eq!(expected_windows.len(), outputs.len());
385        // The execution pops outputs from the end, not the front.
386        for (output, window_indices) in outputs.iter().rev().zip(expected_windows) {
387            let mut expected: HashSet<_> = window_indices
388                .iter()
389                .flat_map(|&i| windows[i].iter().map(FileMeta::file_id))
390                .collect();
391            if window_indices.contains(&2) {
392                expected.insert(dependency_id);
393            }
394            assert_eq!(expected, file_ids(&output.inputs));
395        }
396
397        // The LastRow picker must retain its original seed cap and priority.
398        region.region_options.merge_mode = Some(MergeMode::LastRow);
399        let picker = new_picker(&request, &region.region_options, limit, None, false);
400        let outputs = picker
401            .pick(&region)
402            .await
403            .unwrap()
404            .map_or_else(Vec::new, |picked| picked.outputs);
405        assert_eq!(limit.unwrap_or(3).min(3), outputs.len());
406        for (output, window) in outputs.iter().rev().zip(windows.iter().rev()) {
407            let expected: HashSet<_> = window.iter().map(FileMeta::file_id).collect();
408            assert_eq!(expected, file_ids(&output.inputs));
409        }
410    }
411
412    #[tokio::test]
413    async fn test_budget_excluded_l1_keeps_waiting_across_l0_picks() {
414        // R4-C1: an independent historical L1 seed must not consume a turn merely
415        // because it was enumerated for closure while the single slot serves L0.
416        let files = [(0, 1), (7200, 0)].into_iter().flat_map(|(start, level)| {
417            (0..4).map(move |i| FileMeta {
418                file_id: FileId::random(),
419                time_range: (
420                    Timestamp::new_second(start),
421                    Timestamp::new_second(start + 10),
422                ),
423                level,
424                file_size: 100,
425                sequence: std::num::NonZeroU64::new((start + i + 1) as u64),
426                ..Default::default()
427            })
428        });
429        let mut region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
430        region.ttl = None;
431        region.region_options.merge_mode = Some(MergeMode::LastNonNull);
432        let CompactionOptions::Twcs(opts) = &mut region.region_options.compaction;
433        opts.time_window = Some(Duration::from_secs(3600));
434        opts.inactive_window_l1_merge_trigger = 4;
435
436        let old = PickKey {
437            window: 0,
438            class: PickClass::FileReduction,
439        };
440        let mut history = PickHistory::default();
441        let start = Instant::now();
442        history.observe_for_test([old].into_iter(), start);
443        for _ in 0..3 {
444            let picked = pick_automatic_for_test(&region, &mut history, Some(1))
445                .await
446                .unwrap()
447                .unwrap();
448            assert_eq!(1, picked.outputs.len());
449            assert_eq!(4, picked.outputs[0].inputs.len());
450            assert!(
451                picked.outputs[0]
452                    .inputs
453                    .iter()
454                    .all(|file| file.level() == 0)
455            );
456            assert_eq!(
457                Some(start),
458                history.promoted_since_for_test(old, start + Duration::from_secs(120))
459            );
460        }
461
462        // Advance only the waiting age, without sleeping or acknowledging any
463        // execution. The same hot L0 remains eligible in the unchanged snapshot.
464        history.seed_for_test(old, start - Duration::from_secs(120));
465        let picked = pick_automatic_for_test(&region, &mut history, Some(1))
466            .await
467            .unwrap()
468            .unwrap();
469        assert_eq!(1, picked.outputs.len());
470        assert_eq!(4, picked.outputs[0].inputs.len());
471        assert!(
472            picked.outputs[0]
473                .inputs
474                .iter()
475                .all(|file| file.level() == 1)
476        );
477        assert!(
478            history
479                .promoted_since_for_test(old, Instant::now())
480                .is_none()
481        );
482    }
483
484    // Cover absent/unknown boundaries and strict inequality without arithmetic
485    // on sequence numbers, including the maximum representable sequence.
486    #[rstest::rstest]
487    #[case(0, None, true)]
488    #[case(0, Some(2), false)]
489    #[case(1, Some(0), false)]
490    #[case(1, Some(2), true)]
491    #[case(2, Some(2), false)]
492    #[case(3, Some(2), false)]
493    #[case(u64::MAX - 1, Some(u64::MAX), true)]
494    #[case(u64::MAX, Some(u64::MAX), false)]
495    fn test_inputs_must_precede_pending_memtables(
496        #[case] file_sequence: u64,
497        #[case] memtable_min: Option<u64>,
498        #[case] expected: bool,
499    ) {
500        let file =
501            new_file_handle_with_size_and_sequence(FileId::random(), 0, 10, 0, file_sequence, 100);
502        assert_eq!(expected, inputs_precede_memtables(&[file], memtable_min));
503    }
504
505    #[test]
506    fn test_transitive_closure_exceeds_seed_limit_across_levels_and_windows() {
507        let metadata = metadata_for_test();
508        // Inclusive endpoints connect neighbors across hours and both levels.
509        // A one-pass expansion or applying the TWCS seed cap loses the tail.
510        let files: Vec<_> = (0..32)
511            .map(|i| {
512                new_file_handle(
513                    FileId::random(),
514                    i * 3_600_000,
515                    (i + 1) * 3_600_000,
516                    (i % 2) as u8,
517                )
518            })
519            .collect();
520        let (outputs, _) = build_closed_outputs(
521            vec![seed(vec![files[0].clone()])],
522            files.clone(),
523            &metadata,
524            ClosureLimits::default(),
525        );
526        assert_eq!(1, outputs.len());
527        assert_eq!(files.len(), outputs[0].inputs.len());
528        assert_eq!(file_ids(&files), file_ids(&outputs[0].inputs));
529    }
530
531    #[rstest::rstest]
532    #[case::unlimited(false, None, vec![vec![0, 1, 2, 3], vec![4]], vec![0, 1, 2])]
533    #[case::busy_unlimited(true, None, vec![vec![4]], vec![0, 1, 2])]
534    #[case::one_slot(false, Some(1), vec![vec![0, 1, 2, 3]], vec![1, 2])]
535    #[case::busy_one_slot(true, Some(1), vec![vec![4]], vec![0, 1, 2])]
536    #[case::zero_budget(false, Some(0), vec![], vec![])]
537    #[case::busy_zero_budget(true, Some(0), vec![], vec![])]
538    fn test_absorbed_seed_closes_disconnected_members_and_defers_busy_group(
539        #[case] busy: bool,
540        #[case] max_outputs: Option<usize>,
541        #[case] expected_groups: Vec<Vec<usize>>,
542        #[case] expected_attempted: Vec<usize>,
543    ) {
544        let metadata = metadata_for_test();
545        let files: Vec<_> = [(0, 10), (10, 20), (100, 110), (110, 120), (200, 210)]
546            .into_iter()
547            .map(|(start, end)| new_file_handle(FileId::random(), start, end, 0))
548            .collect();
549        files[3].set_compacting(busy);
550        let mut priority_seed = seed(vec![files[0].clone()]);
551        priority_seed.filter_deleted = true;
552        let seeds = vec![
553            seed(vec![files[4].clone()]),
554            seed(vec![files[1].clone(), files[2].clone()]),
555            priority_seed,
556        ];
557
558        let (outputs, attempted) = build_closed_outputs(
559            seeds,
560            files.clone(),
561            &metadata,
562            ClosureLimits {
563                max_outputs,
564                ..Default::default()
565            },
566        );
567        assert_eq!(expected_attempted, attempted);
568        assert_eq!(expected_groups.len(), outputs.len());
569        // Compare in execution priority order: the compactor pops from the end.
570        for (output, indices) in outputs.iter().rev().zip(expected_groups) {
571            let expected: HashSet<_> = indices
572                .iter()
573                .map(|&index| files[index].file_id())
574                .collect();
575            assert_eq!(indices.len(), output.inputs.len());
576            assert_eq!(expected, file_ids(&output.inputs));
577            // Preserve the absorbed seed's conservative tombstone policy.
578            assert!(!output.filter_deleted);
579        }
580    }
581
582    #[rstest::rstest]
583    #[case("chain", 2048)]
584    #[case("dense_with_external", 1024)]
585    #[case("time_dense_pk_disjoint", 1)]
586    fn test_large_snapshot_closure(#[case] shape: &str, #[case] expected_count: usize) {
587        use crate::compaction::test_util::{
588            new_file_handle_with_size_sequence_and_primary_key_range, pk_range,
589            primary_key_metadata_for_test,
590        };
591
592        let metadata = primary_key_metadata_for_test();
593        let files = (0..2048)
594            .map(|i| {
595                let (start, end, pk) = match shape {
596                    "chain" => (i, i + 1, None),
597                    "dense_with_external" if i < 1024 => (0, 1, None),
598                    "dense_with_external" => (i, i, None),
599                    "time_dense_pk_disjoint" => {
600                        let key = i.to_string();
601                        (0, 1, pk_range(key.as_bytes(), key.as_bytes()))
602                    }
603                    _ => unreachable!(),
604                };
605                new_file_handle_with_size_sequence_and_primary_key_range(
606                    FileId::random(),
607                    start,
608                    end,
609                    0,
610                    i as u64 + 1,
611                    100,
612                    pk,
613                )
614            })
615            .collect::<Vec<_>>();
616        let (outputs, _) = build_closed_outputs(
617            vec![seed(vec![files[0].clone()])],
618            files.clone(),
619            &metadata,
620            ClosureLimits::default(),
621        );
622        assert_eq!(1, outputs.len());
623        assert_eq!(expected_count, outputs[0].inputs.len());
624        assert_eq!(
625            file_ids(&files[..expected_count]),
626            file_ids(&outputs[0].inputs)
627        );
628    }
629
630    #[rstest::rstest]
631    #[case(MergeMode::LastRow, false, None, 4)]
632    #[case(MergeMode::LastRow, true, None, 4)]
633    #[case(MergeMode::LastNonNull, false, None, 5)]
634    #[case(MergeMode::LastNonNull, true, None, 0)]
635    #[case(MergeMode::LastRow, false, Some(5), 4)]
636    #[case(MergeMode::LastNonNull, false, Some(5), 0)]
637    #[case(MergeMode::LastNonNull, false, Some(6), 5)]
638    #[tokio::test]
639    async fn test_picker_dependencies_outside_request_window(
640        #[case] merge_mode: MergeMode,
641        #[case] busy: bool,
642        #[case] memtable_min: Option<u64>,
643        #[case] expected_count: usize,
644    ) {
645        let files = (0..5).map(|i| FileMeta {
646            file_id: FileId::random(),
647            time_range: (
648                Timestamp::new_second(0),
649                Timestamp::new_second(if i == 4 { 3601 } else { 10 }),
650            ),
651            level: if i == 4 { 1 } else { 0 },
652            file_size: if i == 4 { 1_000_000 } else { 100 },
653            sequence: std::num::NonZeroU64::new(i + 1),
654            ..Default::default()
655        });
656        let mut region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
657        region.ttl = None;
658        region.region_options.merge_mode = Some(merge_mode);
659        region.current_version.memtable_min_sequence = memtable_min;
660        let CompactionOptions::Twcs(opts) = &mut region.region_options.compaction;
661        opts.time_window = Some(Duration::from_secs(3600));
662        let dependency = region.current_version.ssts.levels()[1]
663            .files()
664            .next()
665            .unwrap();
666        dependency.set_compacting(busy);
667        let picker = new_picker(
668            &compact_request::Options::Regular(Default::default()),
669            &region.region_options,
670            Some(1),
671            TimestampRange::new(Timestamp::new_second(0), Timestamp::new_second(3600)),
672            false,
673        );
674
675        let picked = picker.pick(&region).await.unwrap();
676        if expected_count == 0 {
677            assert!(picked.is_none());
678        } else {
679            let picked = picked.unwrap();
680            assert_eq!(1, picked.outputs.len());
681            assert_eq!(expected_count, picked.outputs[0].inputs.len());
682            assert_eq!(
683                merge_mode == MergeMode::LastNonNull,
684                picked.outputs[0]
685                    .inputs
686                    .iter()
687                    .any(|file| file.file_id() == dependency.file_id())
688            );
689        }
690    }
691
692    #[rstest::rstest]
693    #[case(MergeMode::LastNonNull, None, 3)]
694    #[case(MergeMode::LastNonNull, Some(2), 0)]
695    #[case(MergeMode::LastRow, Some(2), 3)]
696    #[tokio::test]
697    async fn test_strict_window_keeps_all_versions_in_disjoint_output_slices(
698        #[case] merge_mode: MergeMode,
699        #[case] memtable_min: Option<u64>,
700        #[case] expected_outputs: usize,
701    ) {
702        let files: Vec<_> = [(0, 10), (10, 10), (10, 20)]
703            .into_iter()
704            .enumerate()
705            .map(|(i, (start, end))| {
706                new_file_handle_with_size_and_sequence(
707                    FileId::random(),
708                    start * 1000,
709                    end * 1000,
710                    0,
711                    i as u64 + 1,
712                    100,
713                )
714                .meta_ref()
715                .clone()
716            })
717            .collect();
718        let mut region = compaction_region_with_ssts(files, Duration::from_secs(60)).await;
719        region.region_options.merge_mode = Some(merge_mode);
720        region.current_version.options.merge_mode = Some(merge_mode);
721        region.current_version.memtable_min_sequence = memtable_min;
722        let picker = new_picker(
723            &compact_request::Options::StrictWindow(api::v1::region::StrictWindow {
724                window_seconds: 10,
725            }),
726            &region.region_options,
727            Some(1),
728            TimestampRange::new(Timestamp::new_second(10), Timestamp::new_second(20)),
729            false,
730        );
731
732        let picked = picker.pick(&region).await.unwrap().unwrap();
733        // Cross-window inputs also need their outer slices rewritten, despite
734        // requesting only [10, 20). All three versions at t=10 share one stream.
735        // With pending sequence 2, [0, 10) alone is safe, but must not be
736        // rewritten without the unsafe windows sharing its input SST.
737        assert_eq!(expected_outputs, picked.outputs.len());
738        for (i, output) in picked.outputs.iter().enumerate() {
739            assert_eq!(
740                TimestampRange::new(
741                    Timestamp::new_second(i as i64 * 10),
742                    Timestamp::new_second((i as i64 + 1) * 10),
743                ),
744                output.output_time_range
745            );
746            assert_eq!(if i == 1 { 3 } else { 1 }, output.inputs.len());
747            assert!(!output.filter_deleted);
748        }
749    }
750
751    #[tokio::test]
752    async fn test_busy_closure_keeps_expired_files_without_rewriting_them() {
753        let now = Timestamp::current_millis().value();
754        let files = (0..6).map(|i| FileMeta {
755            file_id: FileId::random(),
756            time_range: (
757                Timestamp::new_millisecond(if i == 5 { 0 } else { now - 10_000 }),
758                Timestamp::new_millisecond(if i == 5 { 10 } else { now }),
759            ),
760            level: if i == 4 { 1 } else { 0 },
761            file_size: if i == 4 { 1_000_000 } else { 100 },
762            sequence: std::num::NonZeroU64::new(i + 1),
763            ..Default::default()
764        });
765        let mut region = compaction_region_with_ssts(files, Duration::from_secs(3600)).await;
766        region.region_options.merge_mode = Some(MergeMode::LastNonNull);
767        let dependency = region.current_version.ssts.levels()[1]
768            .files()
769            .next()
770            .unwrap();
771        dependency.set_compacting(true);
772        let picker = new_picker(
773            &compact_request::Options::Regular(Default::default()),
774            &region.region_options,
775            Some(1),
776            None,
777            false,
778        );
779        let picked = picker.pick(&region).await.unwrap().unwrap();
780        assert!(picked.outputs.is_empty());
781        assert_eq!(1, picked.expired_ssts.len());
782        assert_eq!(
783            Timestamp::new_millisecond(10),
784            picked.expired_ssts[0].time_range().1
785        );
786        assert!(!picked.expired_ssts[0].compacting());
787
788        dependency.set_compacting(false);
789        let picked = picker.pick(&region).await.unwrap().unwrap();
790        assert_eq!(1, picked.outputs.len());
791        assert_eq!(5, picked.outputs[0].inputs.len());
792        assert_eq!(1, picked.expired_ssts.len());
793        assert!(!file_ids(&picked.outputs[0].inputs).contains(&picked.expired_ssts[0].file_id()));
794    }
795}