Skip to main content

mito2/compaction/
compactor.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::num::NonZeroU64;
16use std::sync::Arc;
17use std::time::Duration;
18
19use common_base::Plugins;
20use common_base::cancellation::{CancellableFuture, CancellationHandle};
21use common_meta::key::SchemaMetadataManagerRef;
22use common_telemetry::{debug, info, warn};
23use common_time::TimeToLive;
24use either::Either;
25use itertools::Itertools;
26use object_store::manager::ObjectStoreManagerRef;
27use partition::expr::PartitionExpr;
28use serde::{Deserialize, Serialize};
29use snafu::{OptionExt, ResultExt};
30use store_api::ManifestVersion;
31use store_api::metadata::RegionMetadataRef;
32use store_api::region_request::PathType;
33use store_api::storage::{RegionId, SequenceNumber};
34
35use crate::access_layer::{
36    AccessLayer, AccessLayerRef, Metrics, OperationType, SstWriteRequest, WriteType,
37};
38use crate::cache::{CacheManager, CacheManagerRef};
39use crate::compaction::picker::PickerOutput;
40use crate::compaction::reader::CompactionSstReaderBuilder;
41use crate::compaction::{CompactionOutput, find_dynamic_options};
42use crate::config::MitoConfig;
43use crate::engine::region_hook::{RegionHookRef, SstFileInfo};
44use crate::error;
45use crate::error::{
46    EmptyRegionDirSnafu, InvalidPartitionExprSnafu, ObjectStoreNotFoundSnafu, Result,
47};
48use crate::manifest::action::{RegionEdit, RegionMetaAction, RegionMetaActionList};
49use crate::manifest::manager::{RegionManifestManager, RegionManifestOptions};
50use crate::region::options::{MergeMode, RegionOptions};
51use crate::region::version::VersionRef;
52use crate::region::{ManifestContext, RegionLeaderState, RegionRoleState};
53use crate::schedule::scheduler::LocalScheduler;
54use crate::sst::FormatType;
55use crate::sst::file::{FileHandle, FileMeta, UncommittedSsts};
56use crate::sst::file_purger::LocalFilePurger;
57use crate::sst::index::intermediate::IntermediateManager;
58use crate::sst::index::puffin_manager::PuffinManagerFactory;
59use crate::sst::location::region_dir_from_table_dir;
60use crate::sst::parquet::metadata::extract_primary_key_range;
61use crate::sst::parquet::{SstInfo, WriteOptions};
62use crate::sst::version::{SstVersion, SstVersionRef};
63
64/// Region version for compaction that does not hold memtables.
65#[derive(Clone)]
66pub struct CompactionVersion {
67    /// Metadata of the region.
68    ///
69    /// Altering metadata isn't frequent, storing metadata in Arc to allow sharing
70    /// metadata and reuse metadata when creating a new `Version`.
71    pub(crate) metadata: RegionMetadataRef,
72    /// Options of the region.
73    pub(crate) options: RegionOptions,
74    /// SSTs of the region.
75    pub(crate) ssts: SstVersionRef,
76    /// Lower bound of pending memtable sequences from the same Version as `ssts`.
77    /// None means no barrier; Some(0) conservatively represents an unknown bound.
78    /// Keep only the value so background compaction cannot pin flushed memtables.
79    pub(crate) memtable_min_sequence: Option<SequenceNumber>,
80    /// Inferred compaction time window.
81    pub(crate) compaction_time_window: Option<Duration>,
82}
83
84impl From<VersionRef> for CompactionVersion {
85    fn from(value: VersionRef) -> Self {
86        Self {
87            metadata: value.metadata.clone(),
88            options: value.options.clone(),
89            ssts: value.ssts.clone(),
90            memtable_min_sequence: if value.options.merge_mode() == MergeMode::LastNonNull {
91                value.memtables.min_sequence()
92            } else {
93                None
94            },
95            compaction_time_window: value.compaction_time_window,
96        }
97    }
98}
99
100/// CompactionRegion represents a region that needs to be compacted.
101/// It's the subset of MitoRegion.
102#[derive(Clone)]
103pub struct CompactionRegion {
104    pub region_id: RegionId,
105    pub region_options: RegionOptions,
106
107    pub(crate) engine_config: Arc<MitoConfig>,
108    pub(crate) region_metadata: RegionMetadataRef,
109    pub(crate) cache_manager: CacheManagerRef,
110    /// Access layer to get the table path and path type.
111    pub access_layer: AccessLayerRef,
112    pub(crate) manifest_ctx: Arc<ManifestContext>,
113    pub(crate) current_version: CompactionVersion,
114    pub(crate) file_purger: Option<Arc<LocalFilePurger>>,
115    pub(crate) ttl: Option<TimeToLive>,
116
117    /// Controls the parallelism of this compaction task. Default is 1.
118    ///
119    /// The parallel is inside this compaction task, not across different compaction tasks.
120    /// It can be different windows of the same compaction task or something like this.
121    pub max_parallelism: usize,
122
123    pub(crate) plugins: Plugins,
124}
125
126/// OpenCompactionRegionRequest represents the request to open a compaction region.
127#[derive(Clone)]
128pub struct OpenCompactionRegionRequest {
129    pub region_id: RegionId,
130    pub table_dir: String,
131    pub path_type: PathType,
132    pub region_options: RegionOptions,
133    pub max_parallelism: usize,
134    /// Plugins for the compaction region, used to look up the [`RegionHook`](crate::engine::region_hook::RegionHook).
135    pub plugins: Plugins,
136}
137
138/// Open a compaction region from a compaction request.
139/// It's simple version of RegionOpener::open().
140pub async fn open_compaction_region(
141    req: &OpenCompactionRegionRequest,
142    mito_config: &MitoConfig,
143    object_store_manager: ObjectStoreManagerRef,
144    ttl_provider: Either<TimeToLive, SchemaMetadataManagerRef>,
145) -> Result<CompactionRegion> {
146    let object_store = {
147        let name = &req.region_options.storage;
148        if let Some(name) = name {
149            object_store_manager
150                .find(name)
151                .with_context(|| ObjectStoreNotFoundSnafu {
152                    object_store: name.clone(),
153                })?
154        } else {
155            object_store_manager.default_object_store()
156        }
157    };
158
159    let access_layer = {
160        let puffin_manager_factory = PuffinManagerFactory::new(
161            &mito_config.index.aux_path,
162            mito_config.index.staging_size.as_bytes(),
163            Some(mito_config.index.write_buffer_size.as_bytes() as _),
164            mito_config.index.staging_ttl,
165        )
166        .await?;
167        let intermediate_manager =
168            IntermediateManager::init_fs(mito_config.index.aux_path.clone()).await?;
169
170        Arc::new(AccessLayer::new(
171            &req.table_dir,
172            req.path_type,
173            object_store.clone(),
174            puffin_manager_factory,
175            intermediate_manager,
176        ))
177    };
178
179    let manifest_manager = {
180        let region_dir = region_dir_from_table_dir(&req.table_dir, req.region_id, req.path_type);
181        let region_manifest_options =
182            RegionManifestOptions::new(mito_config, &region_dir, object_store);
183
184        RegionManifestManager::open(region_manifest_options, &Default::default())
185            .await?
186            .with_context(|| EmptyRegionDirSnafu {
187                region_id: req.region_id,
188                region_dir: region_dir_from_table_dir(&req.table_dir, req.region_id, req.path_type),
189            })?
190    };
191
192    let manifest = manifest_manager.manifest();
193    let region_metadata = manifest.metadata.clone();
194    let hook: Option<RegionHookRef> = req.plugins.get();
195    let manifest_ctx = Arc::new(ManifestContext::new(
196        manifest_manager,
197        RegionRoleState::Leader(RegionLeaderState::Writable),
198        hook,
199    ));
200
201    let file_purger = {
202        let purge_scheduler = Arc::new(LocalScheduler::new(mito_config.max_background_purges));
203        Arc::new(LocalFilePurger::new(
204            purge_scheduler.clone(),
205            access_layer.clone(),
206            None,
207        ))
208    };
209
210    let current_version = {
211        let mut ssts = SstVersion::new(region_metadata.clone());
212        ssts.add_files(file_purger.clone(), manifest.files.values().cloned());
213        CompactionVersion {
214            metadata: region_metadata.clone(),
215            options: req.region_options.clone(),
216            ssts: Arc::new(ssts),
217            // Remote execution uses an already picked output. A manifest-only
218            // opener cannot prove that a new LastNonNull pick is memtable-safe.
219            memtable_min_sequence: Some(0),
220            compaction_time_window: manifest.compaction_time_window,
221        }
222    };
223
224    let ttl = match ttl_provider {
225        // Use the specified ttl.
226        Either::Left(ttl) => ttl,
227        // Get the ttl from the schema metadata manager.
228        Either::Right(schema_metadata_manager) => {
229            let (_, ttl) =
230                find_dynamic_options(req.region_id, &req.region_options, &schema_metadata_manager)
231                    .await
232                    .unwrap_or_else(|e| {
233                        warn!(e; "Failed to get ttl for region: {}", region_metadata.region_id);
234                        (
235                            crate::region::options::CompactionOptions::default(),
236                            TimeToLive::default(),
237                        )
238                    });
239            ttl
240        }
241    };
242
243    Ok(CompactionRegion {
244        region_id: req.region_id,
245        region_options: req.region_options.clone(),
246        engine_config: Arc::new(mito_config.clone()),
247        region_metadata: region_metadata.clone(),
248        cache_manager: Arc::new(CacheManager::default()),
249        access_layer,
250        manifest_ctx,
251        current_version,
252        file_purger: Some(file_purger),
253        ttl: Some(ttl),
254        max_parallelism: req.max_parallelism,
255        plugins: req.plugins.clone(),
256    })
257}
258
259impl CompactionRegion {
260    /// Get the file purger of the compaction region.
261    pub fn file_purger(&self) -> Option<Arc<LocalFilePurger>> {
262        self.file_purger.clone()
263    }
264
265    /// Stop the file purger scheduler of the compaction region.
266    pub async fn stop_purger_scheduler(&self) -> Result<()> {
267        if let Some(file_purger) = &self.file_purger {
268            file_purger.stop_scheduler().await
269        } else {
270            Ok(())
271        }
272    }
273
274    /// Fires [`RegionHook::on_sst_files_written`] for the freshly-merged SST
275    /// files in `merge_output`. Shared by both compaction paths, it must run
276    /// before [`Compactor::update_manifest`], whose `on_manifest_updated`
277    /// drains the per-region state this hook populates.
278    pub async fn invoke_sst_hook(&self, merge_output: &MergeOutput) {
279        let Some(hook) = self.plugins.get::<RegionHookRef>() else {
280            return;
281        };
282
283        // Remote compaction deserializes `MergeOutput` over gRPC, where
284        // `sst_infos` is `#[serde(skip)]`. Rebuild each `SstInfo` from the
285        // matching `FileMeta` so the hook observes real ids/rows/sizes rather
286        // than zeros — see [`sst_info_from_file_meta`] for what stays default.
287        let synthesized: Vec<SstInfo>;
288        let infos: &[SstInfo] = if merge_output.sst_infos.is_empty() {
289            synthesized = merge_output
290                .files_to_add
291                .iter()
292                .map(sst_info_from_file_meta)
293                .collect();
294            &synthesized
295        } else {
296            // `sst_infos` and `files_to_add` are documented as 1:1. If they
297            // ever diverge, `zip` below would silently truncate; warn so the
298            // mismatch is observable rather than dropping files from the hook.
299            if merge_output.sst_infos.len() != merge_output.files_to_add.len() {
300                warn!(
301                    "sst_infos length ({}) does not match files_to_add length ({}) for region {}",
302                    merge_output.sst_infos.len(),
303                    merge_output.files_to_add.len(),
304                    self.region_id
305                );
306            }
307            &merge_output.sst_infos
308        };
309
310        let files: Vec<SstFileInfo<'_>> = merge_output
311            .files_to_add
312            .iter()
313            .zip(infos)
314            .map(|(meta, info)| SstFileInfo {
315                sst_info_ref: info,
316                file_meta: meta,
317            })
318            .collect();
319        hook.on_sst_files_written(self.region_id, &self.region_metadata, &files)
320            .await;
321    }
322}
323
324/// Builds an [`SstInfo`] for the remote-compaction path by copying the scalar
325/// fields that [`FileMeta`] also carries.
326///
327/// Remote compaction runs off-process and ships `MergeOutput` over the wire;
328/// [`MergeOutput::sst_infos`] is `#[serde(skip)]` because the parquet footer
329/// (`ParquetMetaData`) is not serde-serializable. To avoid feeding the hook
330/// default (zero) values for real SSTs, this rebuilds the seven fields `FileMeta`
331/// and `SstInfo` share: `file_id`, `time_range`, `file_size`,
332/// `max_row_group_uncompressed_size`, `num_rows`, `num_row_groups`, `num_series`.
333///
334/// Two fields stay default and **cannot be recovered on the datanode**:
335/// - `file_metadata` — the parquet footer, whose column min/max/null-count
336///   statistics are required by hooks building richer artifacts (e.g. an Iceberg
337///   manifest). That data is produced by the compactor's writer and exists only
338///   in the freshly-written SST on object storage.
339/// - `index_metadata` — `FileMeta` tracks indexes via `available_indexes` /
340///   `indexes`, not as an [`IndexOutput`].
341///
342/// A hook that needs column stats must fetch the footer from object storage
343/// (the [`CompactionRegion`]'s `access_layer` reaches the store), or the footer
344/// must be shipped over the wire (revisit the `#[serde(skip)]` on `sst_infos`).
345/// `num_rows` may read `0` for legacy `FileMeta`s where the count is unknown.
346fn sst_info_from_file_meta(meta: &FileMeta) -> SstInfo {
347    SstInfo {
348        file_id: meta.file_id,
349        time_range: meta.time_range,
350        file_size: meta.file_size,
351        max_row_group_uncompressed_size: meta.max_row_group_uncompressed_size,
352        num_rows: meta.num_rows as usize,
353        num_row_groups: meta.num_row_groups,
354        num_series: meta.num_series,
355        ..Default::default()
356    }
357}
358
359/// `[MergeOutput]` represents the output of merging SST files.
360#[derive(Default, Debug, Serialize, Deserialize)]
361pub struct MergeOutput {
362    pub files_to_add: Vec<FileMeta>,
363    pub files_to_remove: Vec<FileMeta>,
364    pub compaction_time_window: Option<i64>,
365    #[serde(skip)]
366    pub sst_infos: Vec<SstInfo>,
367}
368
369impl MergeOutput {
370    pub fn is_empty(&self) -> bool {
371        self.files_to_add.is_empty() && self.files_to_remove.is_empty()
372    }
373
374    pub fn input_file_size(&self) -> u64 {
375        self.files_to_remove.iter().map(|f| f.file_size).sum()
376    }
377
378    pub fn output_file_size(&self) -> u64 {
379        self.files_to_add.iter().map(|f| f.file_size).sum()
380    }
381}
382
383/// Compactor is the trait that defines the compaction logic.
384#[async_trait::async_trait]
385pub trait Compactor: Send + Sync + 'static {
386    /// Merge SST files for a region.
387    async fn merge_ssts(
388        &self,
389        compaction_region: &CompactionRegion,
390        picker_output: PickerOutput,
391    ) -> Result<MergeOutput>;
392
393    /// Update the manifest after merging SST files.
394    async fn update_manifest(
395        &self,
396        compaction_region: &CompactionRegion,
397        merge_output: MergeOutput,
398    ) -> Result<(RegionEdit, ManifestVersion)>;
399}
400
401/// Trait for merging a single compaction output into SST files.
402///
403/// This is extracted from `DefaultCompactor` to allow injecting mock
404/// implementations in tests.
405#[async_trait::async_trait]
406pub trait SstMerger: Send + Sync + 'static {
407    async fn merge_single_output(
408        &self,
409        compaction_region: CompactionRegion,
410        output: CompactionOutput,
411        write_opts: WriteOptions,
412    ) -> Result<(Vec<FileMeta>, Vec<SstInfo>)>;
413}
414
415/// The production [`SstMerger`] that reads, merges, and writes SST files.
416#[derive(Clone)]
417pub struct DefaultSstMerger;
418
419/// File-level sequence metadata is independent of physical row encoding.
420struct OutputSequenceMetadata {
421    file_sequence: Option<NonZeroU64>,
422    exact_sequence_trusted: bool,
423}
424
425/// Compaction does not admit new data, so it inherits the input sequence bounds
426/// without allocating a new admission marker. Retaining physical row sequences
427/// does not by itself restore exact-read capability for untrusted inputs.
428fn output_sequence_metadata(
429    region: &CompactionRegion,
430    inputs: &[FileHandle],
431) -> OutputSequenceMetadata {
432    let exact_sequence_trusted = region.region_options.preserve_row_sequence
433        && inputs
434            .iter()
435            .all(|f| f.is_effective_target_sequence_trusted(region.region_id));
436    OutputSequenceMetadata {
437        file_sequence: known_max_input_sequence(inputs),
438        exact_sequence_trusted,
439    }
440}
441
442/// Computes the maximum target-domain sequence bound of the output of merging
443/// `inputs`. A bound can be a row maximum or an admission marker; compaction
444/// must preserve both for sequence-based pruning. Unknown bounds must remain
445/// unknown rather than being replaced with a partial maximum.
446fn known_max_input_sequence(inputs: &[FileHandle]) -> Option<NonZeroU64> {
447    let mut max: Option<NonZeroU64> = None;
448    for input in inputs {
449        let sequence = input.meta_ref().sequence?;
450        max = Some(max.map_or(sequence, |current| current.max(sequence)));
451    }
452    max
453}
454
455#[async_trait::async_trait]
456impl SstMerger for DefaultSstMerger {
457    async fn merge_single_output(
458        &self,
459        compaction_region: CompactionRegion,
460        output: CompactionOutput,
461        write_opts: WriteOptions,
462    ) -> Result<(Vec<FileMeta>, Vec<SstInfo>)> {
463        let region_id = compaction_region.region_id;
464        let storage = compaction_region.region_options.storage.clone();
465        let index_options = compaction_region
466            .current_version
467            .options
468            .index_options
469            .clone();
470        let append_mode = compaction_region.current_version.options.append_mode;
471        let merge_mode = compaction_region.current_version.options.merge_mode();
472        let flat_format = compaction_region
473            .region_options
474            .sst_format
475            .map(|format| format == FormatType::Flat)
476            .unwrap_or(compaction_region.engine_config.default_flat_format);
477
478        let index_config = compaction_region.engine_config.index.clone();
479        let inverted_index_config = compaction_region.engine_config.inverted_index.clone();
480        let fulltext_index_config = compaction_region.engine_config.fulltext_index.clone();
481        let bloom_filter_index_config = compaction_region.engine_config.bloom_filter_index.clone();
482
483        let input_file_names = output
484            .inputs
485            .iter()
486            .map(|f| f.file_id().to_string())
487            .join(",");
488        let sequence_metadata = output_sequence_metadata(&compaction_region, &output.inputs);
489        let builder = CompactionSstReaderBuilder {
490            metadata: compaction_region.region_metadata.clone(),
491            sst_layer: compaction_region.access_layer.clone(),
492            cache: compaction_region.cache_manager.clone(),
493            inputs: &output.inputs,
494            append_mode,
495            filter_deleted: output.filter_deleted,
496            time_range: output.output_time_range,
497            merge_mode,
498        };
499        let source = builder.build_flat_sst_reader().await?;
500
501        let mut metrics = Metrics::new(WriteType::Compaction);
502        let region_metadata = compaction_region.region_metadata.clone();
503        let sst_infos = compaction_region
504            .access_layer
505            .write_sst(
506                SstWriteRequest {
507                    op_type: OperationType::Compact,
508                    metadata: region_metadata.clone(),
509                    source,
510                    cache_manager: compaction_region.cache_manager.clone(),
511                    storage,
512                    // Readers resolve file overrides before merge/dedup. Replacing
513                    // their effective sequences here could promote old rows above
514                    // versions in SSTs that were not part of this merge.
515                    max_sequence: None,
516                    sst_write_format: if flat_format {
517                        FormatType::Flat
518                    } else {
519                        FormatType::PrimaryKey
520                    },
521                    preserve_row_sequence: sequence_metadata.exact_sequence_trusted,
522                    index_options,
523                    index_config,
524                    inverted_index_config,
525                    fulltext_index_config,
526                    bloom_filter_index_config,
527                },
528                &write_opts,
529                &mut metrics,
530            )
531            .await?;
532        // Convert partition expression once outside the map
533        let partition_expr = match &region_metadata.partition_expr {
534            None => None,
535            Some(json_str) if json_str.is_empty() => None,
536            Some(json_str) => PartitionExpr::from_json_str(json_str).with_context(|_| {
537                InvalidPartitionExprSnafu {
538                    expr: json_str.clone(),
539                }
540            })?,
541        };
542
543        let output_files = sst_infos
544            .iter()
545            .map(|sst_info| {
546                let pk_range = sst_info
547                    .file_metadata
548                    .as_ref()
549                    .and_then(|meta| extract_primary_key_range(meta, &region_metadata));
550                let (primary_key_min, primary_key_max) = match pk_range {
551                    Some((min, max)) => (Some(min), Some(max)),
552                    None => (None, None),
553                };
554
555                FileMeta {
556                    region_id,
557                    file_id: sst_info.file_id,
558                    time_range: sst_info.time_range,
559                    level: output.output_level,
560                    file_size: sst_info.file_size,
561                    max_row_group_uncompressed_size: sst_info.max_row_group_uncompressed_size,
562                    available_indexes: sst_info.index_metadata.build_available_indexes(),
563                    indexes: sst_info.index_metadata.build_indexes(),
564                    index_file_size: sst_info.index_metadata.file_size,
565                    index_version: 0,
566                    num_rows: sst_info.num_rows as u64,
567                    num_row_groups: sst_info.num_row_groups,
568                    sequence: sequence_metadata.file_sequence,
569                    partition_expr: partition_expr.clone(),
570                    num_series: sst_info.num_series,
571                    primary_key_min,
572                    primary_key_max,
573                    preserve_row_sequence: sequence_metadata.exact_sequence_trusted,
574                }
575            })
576            .collect::<Vec<_>>();
577        let output_file_names = output_files.iter().map(|f| f.file_id.to_string()).join(",");
578        info!(
579            "Region {} compaction inputs: [{}], outputs: [{}], flat_format: {}, metrics: {:?}",
580            region_id, input_file_names, output_file_names, flat_format, metrics
581        );
582        metrics.observe();
583        Ok((output_files, sst_infos.into_iter().collect()))
584    }
585}
586
587/// DefaultCompactor is the default implementation of Compactor.
588///
589/// It is parameterized by an [`SstMerger`] to allow injecting mock
590/// implementations in tests.
591pub struct DefaultCompactor<M = DefaultSstMerger> {
592    merger: M,
593    cancel_handle: Arc<CancellationHandle>,
594    uncommitted: Option<UncommittedSsts>,
595}
596
597#[cfg(test)]
598impl<M: SstMerger> DefaultCompactor<M> {
599    pub fn with_merger(merger: M) -> Self {
600        Self {
601            merger,
602            cancel_handle: Arc::new(CancellationHandle::default()),
603            uncommitted: None,
604        }
605    }
606}
607
608impl DefaultCompactor {
609    /// Creates a new `DefaultCompactor` with the given cancel handle and no
610    /// uncommitted SSTs tracking.
611    ///
612    /// This is the public entry point for external crates that want a
613    /// cancellable compactor without opting into uncommitted-SST tracking.
614    pub fn new_with_cancel_handle(cancel_handle: Arc<CancellationHandle>) -> Self {
615        Self {
616            merger: DefaultSstMerger,
617            cancel_handle,
618            uncommitted: None,
619        }
620    }
621
622    pub(crate) fn with_cancel_handle(
623        cancel_handle: Arc<CancellationHandle>,
624        uncommitted: UncommittedSsts,
625    ) -> Self {
626        Self {
627            merger: DefaultSstMerger,
628            cancel_handle,
629            uncommitted: Some(uncommitted),
630        }
631    }
632}
633
634#[async_trait::async_trait]
635impl<M: SstMerger> Compactor for DefaultCompactor<M>
636where
637    M: Clone,
638{
639    async fn merge_ssts(
640        &self,
641        compaction_region: &CompactionRegion,
642        mut picker_output: PickerOutput,
643    ) -> Result<MergeOutput> {
644        let internal_parallelism = compaction_region.max_parallelism.max(1);
645        let compaction_time_window = picker_output.time_window_size;
646        let region_id = compaction_region.region_id;
647
648        // Build tasks along with their input file metas so we can track which
649        // inputs correspond to each task.
650        let mut tasks: Vec<(Vec<FileMeta>, _)> = Vec::with_capacity(picker_output.outputs.len());
651
652        for output in picker_output.outputs.drain(..) {
653            let inputs_to_remove: Vec<_> =
654                output.inputs.iter().map(|f| f.meta_ref().clone()).collect();
655            let write_opts = WriteOptions {
656                write_buffer_size: compaction_region.engine_config.sst_write_buffer_size,
657                max_file_size: picker_output.max_file_size,
658                row_group_size: compaction_region.region_options.row_group_size(),
659                float_field_encoding: compaction_region.region_options.float_field_encoding,
660            };
661            let merger = self.merger.clone();
662            let compaction_region = compaction_region.clone();
663            let uncommitted = self.uncommitted.clone();
664            let fut = async move {
665                let result = merger
666                    .merge_single_output(compaction_region, output, write_opts)
667                    .await;
668                if let (Some(uncommitted), Ok((_, infos))) = (&uncommitted, &result) {
669                    uncommitted.track(infos);
670                }
671                result
672            };
673            tasks.push((inputs_to_remove, fut));
674        }
675
676        let hook: Option<RegionHookRef> = compaction_region.plugins.get();
677        let mut output_files = Vec::with_capacity(tasks.len());
678        let mut all_sst_infos: Vec<SstInfo> = Vec::new();
679        let mut compacted_inputs = Vec::with_capacity(
680            tasks.iter().map(|(inputs, _)| inputs.len()).sum::<usize>()
681                + picker_output.expired_ssts.len(),
682        );
683
684        while !tasks.is_empty() {
685            let mut chunk: Vec<(Vec<FileMeta>, _)> = Vec::with_capacity(internal_parallelism);
686            for _ in 0..internal_parallelism {
687                if let Some(task) = tasks.pop() {
688                    chunk.push(task);
689                }
690            }
691            let mut spawned: Vec<_> = chunk
692                .into_iter()
693                .map(|(inputs, fut)| {
694                    let handle = common_runtime::spawn_compact(fut);
695                    (inputs, handle)
696                })
697                .collect();
698
699            while let Some((inputs, mut handle)) = spawned.pop() {
700                let abort_handle = handle.abort_handle();
701                match CancellableFuture::new(&mut handle, self.cancel_handle.clone()).await {
702                    Ok(Ok(Ok((files, infos)))) => {
703                        output_files.extend(files);
704                        if hook.is_some() {
705                            all_sst_infos.extend(infos);
706                        }
707                        compacted_inputs.extend(inputs);
708                    }
709                    Ok(Ok(Err(e))) => {
710                        warn!(
711                            e; "Failed to merge compaction output for region: {}, inputs: [{}]",
712                            region_id,
713                            inputs.iter().map(|f| f.file_id.to_string()).join(",")
714                        );
715                    }
716                    Ok(Err(e)) => {
717                        warn!(
718                            "Region {} compaction task join error for inputs: [{}], skipping: {}",
719                            region_id,
720                            inputs.iter().map(|f| f.file_id.to_string()).join(","),
721                            e
722                        );
723                        // If the cancel handle is cancelled,
724                        // cancel the remaining tasks before returns the error.
725                        if self.cancel_handle.is_cancelled() {
726                            abort_handle.abort();
727                            for (_, handle) in &spawned {
728                                handle.abort();
729                            }
730                            for (_, handle) in spawned {
731                                let _ = handle.await;
732                            }
733                        }
734                        return Err(e).context(error::JoinSnafu);
735                    }
736                    Err(_) => {
737                        debug!(
738                            "Compaction merge cancelled for region: {}, aborting remaining {} spawned tasks",
739                            region_id,
740                            spawned.len(),
741                        );
742                        abort_handle.abort();
743                        for (_, handle) in &spawned {
744                            handle.abort();
745                        }
746                        let _ = handle.await;
747                        for (_, handle) in spawned {
748                            let _ = handle.await;
749                        }
750                        break;
751                    }
752                }
753            }
754
755            if self.cancel_handle.is_cancelled() {
756                info!("Compaction merge cancelled for region: {}", region_id);
757                break;
758            }
759        }
760
761        // Include expired SSTs in removals — these don't depend on merge success.
762        compacted_inputs.extend(
763            picker_output
764                .expired_ssts
765                .iter()
766                .map(|f| f.meta_ref().clone()),
767        );
768
769        Ok(MergeOutput {
770            files_to_add: output_files,
771            files_to_remove: compacted_inputs,
772            compaction_time_window: Some(compaction_time_window),
773            sst_infos: all_sst_infos,
774        })
775    }
776
777    async fn update_manifest(
778        &self,
779        compaction_region: &CompactionRegion,
780        merge_output: MergeOutput,
781    ) -> Result<(RegionEdit, ManifestVersion)> {
782        // Write region edit to manifest.
783        let edit = RegionEdit {
784            files_to_add: merge_output.files_to_add,
785            files_to_remove: merge_output.files_to_remove,
786            // Use current timestamp as the edit timestamp.
787            timestamp_ms: Some(chrono::Utc::now().timestamp_millis()),
788            compaction_time_window: merge_output
789                .compaction_time_window
790                .map(|seconds| Duration::from_secs(seconds as u64)),
791            flushed_entry_id: None,
792            flushed_sequence: None,
793            committed_sequence: None,
794        };
795
796        let action_list = RegionMetaActionList::with_action(RegionMetaAction::Edit(edit.clone()));
797        // TODO: We might leak files if we fail to update manifest. We can add a cleanup task to remove them later.
798        let manifest_version = compaction_region
799            .manifest_ctx
800            .update_manifest_for_compaction(action_list)
801            .await?;
802
803        Ok((edit, manifest_version))
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use std::sync::atomic::{AtomicUsize, Ordering};
810    use std::sync::{Arc, Mutex};
811    use std::time::Duration;
812
813    use store_api::storage::{FileId, RegionId};
814    use tokio::time::sleep;
815
816    use super::{DefaultCompactor, *};
817    use crate::cache::CacheManager;
818    use crate::compaction::picker::PickerOutput;
819    use crate::error::Result;
820    use crate::sst::file::FileHandle;
821    use crate::sst::file_purger::NoopFilePurger;
822    use crate::sst::version::SstVersion;
823    use crate::test_util::memtable_util::metadata_for_test;
824    use crate::test_util::scheduler_util::SchedulerEnv;
825
826    fn dummy_file_meta() -> FileMeta {
827        FileMeta {
828            region_id: RegionId::new(1, 1),
829            file_id: FileId::random(),
830            file_size: 100,
831            ..Default::default()
832        }
833    }
834
835    fn new_file_handle(meta: FileMeta) -> FileHandle {
836        FileHandle::new(meta, Arc::new(NoopFilePurger))
837    }
838
839    #[test]
840    fn test_effective_target_trust_and_max_sequence() {
841        let target = RegionId::new(1, 1);
842        let local_trusted = new_file_handle(FileMeta {
843            region_id: target,
844            sequence: NonZeroU64::new(3),
845            preserve_row_sequence: true,
846            ..dummy_file_meta()
847        });
848        let foreign_untrusted_marker = new_file_handle(FileMeta {
849            region_id: RegionId::new(1, 2),
850            sequence: NonZeroU64::new(9),
851            preserve_row_sequence: false,
852            ..dummy_file_meta()
853        });
854        let local_untrusted = new_file_handle(FileMeta {
855            region_id: target,
856            sequence: NonZeroU64::new(11),
857            preserve_row_sequence: false,
858            ..dummy_file_meta()
859        });
860        assert!(local_trusted.is_effective_target_sequence_trusted(target));
861        assert!(foreign_untrusted_marker.is_effective_target_sequence_trusted(target));
862        assert!(!local_untrusted.is_effective_target_sequence_trusted(target));
863        assert_eq!(
864            NonZeroU64::new(9),
865            known_max_input_sequence(&[local_trusted, foreign_untrusted_marker])
866        );
867    }
868
869    #[test]
870    fn test_known_max_input_sequence() {
871        let meta_with = |sequence| FileMeta {
872            sequence,
873            ..dummy_file_meta()
874        };
875
876        let inputs = vec![
877            new_file_handle(meta_with(NonZeroU64::new(3))),
878            new_file_handle(meta_with(NonZeroU64::new(9))),
879            new_file_handle(meta_with(NonZeroU64::new(5))),
880        ];
881        assert_eq!(NonZeroU64::new(9), known_max_input_sequence(&inputs));
882
883        let inputs = vec![
884            new_file_handle(meta_with(NonZeroU64::new(9))),
885            new_file_handle(meta_with(None)),
886        ];
887        assert_eq!(None, known_max_input_sequence(&inputs));
888
889        assert_eq!(None, known_max_input_sequence(&[]));
890    }
891
892    #[tokio::test]
893    async fn test_output_sequence_metadata_preserves_trust_and_unknown_bounds() {
894        let mut region = new_test_compaction_region().await;
895        let local = region.region_id;
896        let foreign = RegionId::new(1, 2);
897        let file = |region_id, sequence: Option<u64>, preserve_row_sequence| {
898            new_file_handle(FileMeta {
899                region_id,
900                sequence: sequence.and_then(NonZeroU64::new),
901                preserve_row_sequence,
902                ..dummy_file_meta()
903            })
904        };
905        // Bounds are independent of row trust: known, mixed trust, unknown in
906        // either sequence domain, empty inputs, and the largest possible bound.
907        let cases = [
908            (
909                vec![file(local, Some(3), true), file(foreign, Some(9), false)],
910                Some(9),
911                true,
912            ),
913            (
914                vec![file(local, Some(3), true), file(local, Some(6), false)],
915                Some(6),
916                false,
917            ),
918            (
919                vec![file(foreign, Some(9), false), file(foreign, Some(11), true)],
920                Some(11),
921                true,
922            ),
923            (
924                vec![file(local, Some(3), true), file(local, None, true)],
925                None,
926                true,
927            ),
928            (
929                vec![file(local, None, false), file(local, Some(6), true)],
930                None,
931                false,
932            ),
933            (
934                vec![file(local, Some(3), true), file(foreign, None, true)],
935                None,
936                false,
937            ),
938            (vec![], None, true),
939            (
940                vec![file(local, Some(u64::MAX), false)],
941                Some(u64::MAX),
942                false,
943            ),
944        ];
945        for committed_sequence in [100, 101] {
946            region
947                .manifest_ctx
948                .manifest_manager
949                .write()
950                .await
951                .update(
952                    RegionMetaActionList::with_action(RegionMetaAction::Edit(RegionEdit {
953                        committed_sequence: Some(committed_sequence),
954                        files_to_add: vec![],
955                        files_to_remove: vec![],
956                        timestamp_ms: None,
957                        compaction_time_window: None,
958                        flushed_entry_id: None,
959                        flushed_sequence: None,
960                    })),
961                    false,
962                )
963                .await
964                .unwrap();
965            for preserve in [false, true] {
966                region.region_options.preserve_row_sequence = preserve;
967                for (inputs, known_bound, inputs_trusted) in &cases {
968                    let metadata = output_sequence_metadata(&region, inputs);
969                    let trusted = preserve && *inputs_trusted;
970                    assert_eq!(trusted, metadata.exact_sequence_trusted);
971                    assert_eq!(
972                        known_bound.and_then(NonZeroU64::new),
973                        metadata.file_sequence
974                    );
975                }
976            }
977        }
978    }
979
980    /// Independent outputs must be safe in either visibility order, including when
981    /// only one output succeeds. Merely committing both outputs together is not a
982    /// substitute for preserving version order across their separate merge streams.
983    #[rstest::rstest]
984    #[case(true)]
985    #[case(false)]
986    #[tokio::test]
987    async fn test_independent_outputs_preserve_delete_in_every_visibility_state(
988        #[case] flat_format: bool,
989        #[values(false, true)] unknown_put_bound: bool,
990    ) {
991        use datatypes::arrow::array::AsArray;
992        use datatypes::arrow::datatypes::TimestampMillisecondType;
993        use store_api::region_engine::RegionEngine;
994        use store_api::region_request::RegionRequest;
995
996        use crate::compaction::CompactionOutput;
997        use crate::compaction::compactor::{CompactionRegion, Compactor, DefaultCompactor};
998        use crate::compaction::picker::PickerOutput;
999        use crate::compaction::reader::CompactionSstReaderBuilder;
1000        use crate::engine::compaction_test::{delete_and_flush, put_and_flush};
1001        use crate::region::options::MergeMode;
1002        use crate::sst::file::FileHandle;
1003        use crate::sst::file_purger::NoopFilePurger;
1004        use crate::test_util::{CreateRequestBuilder, TestEnv, rows_schema};
1005
1006        let mut env = TestEnv::new().await;
1007        let region_id = RegionId::new(1, 1);
1008        let config = MitoConfig {
1009            default_flat_format: flat_format,
1010            min_compaction_interval: Duration::from_secs(3600),
1011            ..Default::default()
1012        };
1013        let engine = env.create_engine(config).await;
1014        let request = CreateRequestBuilder::new().build();
1015        let columns = rows_schema(&request);
1016        engine
1017            .handle_request(region_id, RegionRequest::Create(request))
1018            .await
1019            .unwrap();
1020        put_and_flush(&engine, region_id, &columns, 0..1).await;
1021        delete_and_flush(&engine, region_id, &columns, 0..1).await;
1022        put_and_flush(&engine, region_id, &columns, 10..11).await;
1023        let region = engine.get_region(region_id).unwrap();
1024        let version = region.version();
1025        let mut files: Vec<_> = version
1026            .ssts
1027            .levels()
1028            .iter()
1029            .flat_map(|level| level.files())
1030            .cloned()
1031            .collect();
1032        assert_eq!(3, files.len());
1033        files.sort_unstable_by_key(|file| file.meta_ref().sequence);
1034        if unknown_put_bound {
1035            // Model a legacy SST whose physical rows are readable but whose
1036            // file-level boundary is unknown. Merging cannot invent that bound.
1037            let mut meta = files[0].meta_ref().clone();
1038            meta.sequence = None;
1039            files[0] = FileHandle::new(meta, Arc::new(NoopFilePurger));
1040        }
1041        let compaction_region = CompactionRegion {
1042            region_id,
1043            region_options: version.options.clone(),
1044            engine_config: Arc::new(MitoConfig {
1045                default_flat_format: flat_format,
1046                ..Default::default()
1047            }),
1048            region_metadata: version.metadata.clone(),
1049            cache_manager: engine.cache_manager(),
1050            access_layer: region.access_layer.clone(),
1051            manifest_ctx: region.manifest_ctx.clone(),
1052            current_version: version.into(),
1053            file_purger: None,
1054            ttl: None,
1055            max_parallelism: 2,
1056            plugins: common_base::Plugins::new(),
1057        };
1058        let puts = vec![files[0].clone(), files[2].clone()];
1059        let deletes = vec![files[1].clone()];
1060        let picker_output = PickerOutput {
1061            outputs: [puts.clone(), deletes.clone()]
1062                .into_iter()
1063                .map(|inputs| CompactionOutput {
1064                    output_level: 1,
1065                    inputs,
1066                    filter_deleted: false,
1067                    output_time_range: None,
1068                })
1069                .collect(),
1070            expired_ssts: vec![],
1071            time_window_size: 3600,
1072            max_file_size: None,
1073        };
1074        let merged = DefaultCompactor::with_merger(DefaultSstMerger)
1075            .merge_ssts(&compaction_region, picker_output)
1076            .await
1077            .unwrap();
1078        assert_eq!(2, merged.files_to_add.len());
1079        let outputs: Vec<_> = merged
1080            .files_to_add
1081            .into_iter()
1082            .map(|meta| FileHandle::new(meta, Arc::new(NoopFilePurger)))
1083            .collect();
1084        let put_output = outputs
1085            .iter()
1086            .find(|file| file.meta_ref().num_rows == 2)
1087            .unwrap();
1088        let delete_output = outputs
1089            .iter()
1090            .find(|file| file.meta_ref().num_rows == 1)
1091            .unwrap();
1092        assert_eq!(
1093            if unknown_put_bound {
1094                None
1095            } else {
1096                NonZeroU64::new(3)
1097            },
1098            put_output.meta_ref().sequence,
1099        );
1100        assert_eq!(NonZeroU64::new(2), delete_output.meta_ref().sequence);
1101        assert!(
1102            outputs
1103                .iter()
1104                .all(|file| !file.meta_ref().preserve_row_sequence)
1105        );
1106
1107        // Cover the original snapshot, each partial success/cancellation state,
1108        // and both completed outputs using real persisted SSTs and merge readers.
1109        for (put_done, delete_done) in [(false, false), (true, false), (false, true), (true, true)]
1110        {
1111            let mut visible = if put_done {
1112                vec![put_output.clone()]
1113            } else {
1114                puts.clone()
1115            };
1116            visible.extend(if delete_done {
1117                vec![delete_output.clone()]
1118            } else {
1119                deletes.clone()
1120            });
1121            let mut reader = CompactionSstReaderBuilder {
1122                metadata: compaction_region.region_metadata.clone(),
1123                sst_layer: region.access_layer.clone(),
1124                cache: engine.cache_manager(),
1125                inputs: &visible,
1126                append_mode: false,
1127                filter_deleted: true,
1128                time_range: None,
1129                merge_mode: MergeMode::LastRow,
1130            }
1131            .build_flat_sst_reader()
1132            .await
1133            .unwrap();
1134            let mut timestamps = Vec::new();
1135            while let Some(batch) = reader.next_batch().await.unwrap() {
1136                timestamps.extend(
1137                    batch
1138                        .column_by_name("ts")
1139                        .unwrap()
1140                        .as_primitive::<TimestampMillisecondType>()
1141                        .values()
1142                        .iter()
1143                        .copied(),
1144                );
1145            }
1146            assert_eq!(
1147                vec![10_000],
1148                timestamps,
1149                "put_done={put_done}, delete_done={delete_done}"
1150            );
1151        }
1152    }
1153
1154    /// Build a minimal [`CompactionRegion`] suitable for tests where the
1155    /// [`SstMerger`] is mocked and never touches the access layer.
1156    async fn new_test_compaction_region() -> CompactionRegion {
1157        let env = SchedulerEnv::new().await;
1158        let metadata = metadata_for_test();
1159        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
1160        CompactionRegion {
1161            region_id: RegionId::new(1, 1),
1162            region_options: RegionOptions::default(),
1163            engine_config: Arc::new(MitoConfig::default()),
1164            region_metadata: metadata.clone(),
1165            cache_manager: Arc::new(CacheManager::default()),
1166            access_layer: env.access_layer.clone(),
1167            manifest_ctx,
1168            current_version: CompactionVersion {
1169                metadata: metadata.clone(),
1170                options: RegionOptions::default(),
1171                ssts: Arc::new(SstVersion::new(metadata)),
1172                memtable_min_sequence: None,
1173                compaction_time_window: None,
1174            },
1175            file_purger: None,
1176            ttl: None,
1177            max_parallelism: 1,
1178            plugins: Plugins::new(),
1179        }
1180    }
1181
1182    /// An [`SstMerger`] that returns pre-configured results per call index.
1183    ///
1184    /// Call 0 gets `results[0]`, call 1 gets `results[1]`, etc.
1185    #[derive(Clone)]
1186    struct MockMerger {
1187        results: Arc<Mutex<Vec<Result<Vec<FileMeta>>>>>,
1188        call_idx: Arc<AtomicUsize>,
1189    }
1190
1191    impl MockMerger {
1192        fn new(results: Vec<Result<Vec<FileMeta>>>) -> Self {
1193            Self {
1194                results: Arc::new(Mutex::new(results)),
1195                call_idx: Arc::new(AtomicUsize::new(0)),
1196            }
1197        }
1198    }
1199
1200    #[async_trait::async_trait]
1201    impl SstMerger for MockMerger {
1202        async fn merge_single_output(
1203            &self,
1204            _compaction_region: CompactionRegion,
1205            _output: CompactionOutput,
1206            _write_opts: WriteOptions,
1207        ) -> Result<(Vec<FileMeta>, Vec<SstInfo>)> {
1208            let idx = self.call_idx.fetch_add(1, Ordering::SeqCst);
1209            match self.results.lock().unwrap().get(idx) {
1210                Some(Ok(files)) => Ok((files.clone(), Vec::new())),
1211                Some(Err(_)) => error::InvalidMetaSnafu {
1212                    reason: format!("simulated failure at index {idx}"),
1213                }
1214                .fail(),
1215                None => panic!("MockMerger: no result configured for call index {idx}"),
1216            }
1217        }
1218    }
1219
1220    #[tokio::test]
1221    async fn test_partial_merge_failure_collects_only_successful_outputs() {
1222        common_telemetry::init_default_ut_logging();
1223
1224        let compaction_region = new_test_compaction_region().await;
1225
1226        // Prepare 3 compaction outputs: output 0 and 2 succeed, output 1 fails.
1227        let input_meta_0 = dummy_file_meta();
1228        let input_meta_1 = dummy_file_meta();
1229        let input_meta_2 = dummy_file_meta();
1230
1231        let output_meta_0 = vec![dummy_file_meta()];
1232        let output_meta_2 = vec![dummy_file_meta(), dummy_file_meta()];
1233
1234        let merger = MockMerger::new(vec![
1235            Ok(output_meta_0.clone()),
1236            Err(error::InvalidMetaSnafu {
1237                reason: "boom".to_string(),
1238            }
1239            .build()),
1240            Ok(output_meta_2.clone()),
1241        ]);
1242        let compactor = DefaultCompactor::with_merger(merger);
1243
1244        let picker_output = PickerOutput {
1245            outputs: vec![
1246                CompactionOutput {
1247                    output_level: 1,
1248                    inputs: vec![new_file_handle(input_meta_0.clone())],
1249                    filter_deleted: false,
1250                    output_time_range: None,
1251                },
1252                CompactionOutput {
1253                    output_level: 1,
1254                    inputs: vec![new_file_handle(input_meta_1.clone())],
1255                    filter_deleted: false,
1256                    output_time_range: None,
1257                },
1258                CompactionOutput {
1259                    output_level: 1,
1260                    inputs: vec![new_file_handle(input_meta_2.clone())],
1261                    filter_deleted: false,
1262                    output_time_range: None,
1263                },
1264            ],
1265            expired_ssts: vec![],
1266            time_window_size: 3600,
1267            max_file_size: None,
1268        };
1269
1270        let merge_output = compactor
1271            .merge_ssts(&compaction_region, picker_output)
1272            .await
1273            .unwrap();
1274
1275        // Outputs 0 and 2 succeeded (1 + 2 = 3 files added).
1276        assert_eq!(merge_output.files_to_add.len(), 3);
1277        // Only inputs from successful merges should be removed.
1278        assert_eq!(merge_output.files_to_remove.len(), 2);
1279
1280        let removed_ids: Vec<_> = merge_output
1281            .files_to_remove
1282            .iter()
1283            .map(|f| f.file_id)
1284            .collect();
1285        assert!(removed_ids.contains(&input_meta_0.file_id));
1286        assert!(removed_ids.contains(&input_meta_2.file_id));
1287        // The failed output's input must NOT be removed.
1288        assert!(!removed_ids.contains(&input_meta_1.file_id));
1289    }
1290
1291    #[tokio::test]
1292    async fn test_all_outputs_succeed() {
1293        common_telemetry::init_default_ut_logging();
1294
1295        let compaction_region = new_test_compaction_region().await;
1296        let input_meta = dummy_file_meta();
1297        let output_meta = vec![dummy_file_meta()];
1298
1299        let merger = MockMerger::new(vec![Ok(output_meta.clone())]);
1300        let compactor = DefaultCompactor::with_merger(merger);
1301
1302        let picker_output = PickerOutput {
1303            outputs: vec![CompactionOutput {
1304                output_level: 1,
1305                inputs: vec![new_file_handle(input_meta.clone())],
1306                filter_deleted: false,
1307                output_time_range: None,
1308            }],
1309            expired_ssts: vec![],
1310            time_window_size: 3600,
1311            max_file_size: None,
1312        };
1313
1314        let merge_output = compactor
1315            .merge_ssts(&compaction_region, picker_output)
1316            .await
1317            .unwrap();
1318
1319        assert_eq!(merge_output.files_to_add.len(), 1);
1320        assert_eq!(merge_output.files_to_add[0].file_id, output_meta[0].file_id);
1321        assert_eq!(merge_output.files_to_remove.len(), 1);
1322        assert_eq!(merge_output.files_to_remove[0].file_id, input_meta.file_id);
1323    }
1324
1325    #[tokio::test]
1326    async fn test_expired_ssts_always_removed() {
1327        common_telemetry::init_default_ut_logging();
1328
1329        let compaction_region = new_test_compaction_region().await;
1330        let input_meta = dummy_file_meta();
1331        let expired_meta = dummy_file_meta();
1332
1333        // The single merge output fails, but expired SSTs should still be removed.
1334        let merger = MockMerger::new(vec![Err(error::InvalidMetaSnafu {
1335            reason: "fail".to_string(),
1336        }
1337        .build())]);
1338        let compactor = DefaultCompactor::with_merger(merger);
1339
1340        let picker_output = PickerOutput {
1341            outputs: vec![CompactionOutput {
1342                output_level: 1,
1343                inputs: vec![new_file_handle(input_meta.clone())],
1344                filter_deleted: false,
1345                output_time_range: None,
1346            }],
1347            expired_ssts: vec![new_file_handle(expired_meta.clone())],
1348            time_window_size: 3600,
1349            max_file_size: None,
1350        };
1351
1352        let merge_output = compactor
1353            .merge_ssts(&compaction_region, picker_output)
1354            .await
1355            .unwrap();
1356
1357        // No files added (merge failed).
1358        assert!(merge_output.files_to_add.is_empty());
1359        // Only the expired SST should be in files_to_remove (not the failed merge's input).
1360        assert_eq!(merge_output.files_to_remove.len(), 1);
1361        assert_eq!(
1362            merge_output.files_to_remove[0].file_id,
1363            expired_meta.file_id
1364        );
1365    }
1366
1367    #[derive(Clone)]
1368    struct BlockingMerger {
1369        call_idx: Arc<AtomicUsize>,
1370    }
1371
1372    #[async_trait::async_trait]
1373    impl SstMerger for BlockingMerger {
1374        async fn merge_single_output(
1375            &self,
1376            _compaction_region: CompactionRegion,
1377            _output: CompactionOutput,
1378            _write_opts: WriteOptions,
1379        ) -> Result<(Vec<FileMeta>, Vec<SstInfo>)> {
1380            self.call_idx.fetch_add(1, Ordering::SeqCst);
1381            std::future::pending().await
1382        }
1383    }
1384
1385    #[tokio::test(flavor = "multi_thread")]
1386    async fn test_merge_ssts_cancels_spawned_tasks() {
1387        common_telemetry::init_default_ut_logging();
1388
1389        let mut compaction_region = new_test_compaction_region().await;
1390        compaction_region.max_parallelism = 2;
1391
1392        let cancel_handle = Arc::new(CancellationHandle::default());
1393        let call_idx = Arc::new(AtomicUsize::new(0));
1394        let compactor = DefaultCompactor {
1395            merger: BlockingMerger {
1396                call_idx: call_idx.clone(),
1397            },
1398            cancel_handle: cancel_handle.clone(),
1399            uncommitted: None,
1400        };
1401
1402        let picker_output = PickerOutput {
1403            outputs: vec![
1404                CompactionOutput {
1405                    output_level: 1,
1406                    inputs: vec![new_file_handle(dummy_file_meta())],
1407                    filter_deleted: false,
1408                    output_time_range: None,
1409                },
1410                CompactionOutput {
1411                    output_level: 1,
1412                    inputs: vec![new_file_handle(dummy_file_meta())],
1413                    filter_deleted: false,
1414                    output_time_range: None,
1415                },
1416                CompactionOutput {
1417                    output_level: 1,
1418                    inputs: vec![new_file_handle(dummy_file_meta())],
1419                    filter_deleted: false,
1420                    output_time_range: None,
1421                },
1422            ],
1423            expired_ssts: vec![],
1424            time_window_size: 3600,
1425            max_file_size: None,
1426        };
1427
1428        let task = tokio::spawn(async move {
1429            compactor
1430                .merge_ssts(&compaction_region, picker_output)
1431                .await
1432        });
1433
1434        sleep(Duration::from_millis(100)).await;
1435        cancel_handle.cancel();
1436
1437        let merge_output = task
1438            .await
1439            .expect("merge_ssts should stop after cancellation")
1440            .unwrap();
1441
1442        let started = call_idx.load(Ordering::SeqCst);
1443
1444        assert!(merge_output.files_to_add.is_empty());
1445        assert!(merge_output.files_to_remove.is_empty());
1446        assert_eq!(started, 2);
1447    }
1448}