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 index_options = compaction_region
465            .current_version
466            .options
467            .index_options
468            .clone();
469        let append_mode = compaction_region.current_version.options.append_mode;
470        let merge_mode = compaction_region.current_version.options.merge_mode();
471        let flat_format = compaction_region
472            .region_options
473            .sst_format
474            .map(|format| format == FormatType::Flat)
475            .unwrap_or(compaction_region.engine_config.default_flat_format);
476
477        let index_config = compaction_region.engine_config.index.clone();
478        let inverted_index_config = compaction_region.engine_config.inverted_index.clone();
479        let fulltext_index_config = compaction_region.engine_config.fulltext_index.clone();
480        let bloom_filter_index_config = compaction_region.engine_config.bloom_filter_index.clone();
481
482        let input_file_names = output
483            .inputs
484            .iter()
485            .map(|f| f.file_id().to_string())
486            .join(",");
487        let sequence_metadata = output_sequence_metadata(&compaction_region, &output.inputs);
488        let builder = CompactionSstReaderBuilder {
489            metadata: compaction_region.region_metadata.clone(),
490            sst_layer: compaction_region.access_layer.clone(),
491            cache: compaction_region.cache_manager.clone(),
492            inputs: &output.inputs,
493            append_mode,
494            filter_deleted: output.filter_deleted,
495            time_range: output.output_time_range,
496            merge_mode,
497        };
498        let source = builder.build_flat_sst_reader().await?;
499
500        let mut metrics = Metrics::new(WriteType::Compaction);
501        let region_metadata = compaction_region.region_metadata.clone();
502        let sst_infos = compaction_region
503            .access_layer
504            .write_sst(
505                SstWriteRequest {
506                    op_type: OperationType::Compact,
507                    metadata: region_metadata.clone(),
508                    source,
509                    cache_manager: compaction_region.cache_manager.clone(),
510                    // Readers resolve file overrides before merge/dedup. Replacing
511                    // their effective sequences here could promote old rows above
512                    // versions in SSTs that were not part of this merge.
513                    max_sequence: None,
514                    sst_write_format: if flat_format {
515                        FormatType::Flat
516                    } else {
517                        FormatType::PrimaryKey
518                    },
519                    preserve_row_sequence: sequence_metadata.exact_sequence_trusted,
520                    index_options,
521                    index_config,
522                    inverted_index_config,
523                    fulltext_index_config,
524                    bloom_filter_index_config,
525                },
526                &write_opts,
527                &mut metrics,
528            )
529            .await?;
530        // Convert partition expression once outside the map
531        let partition_expr = match &region_metadata.partition_expr {
532            None => None,
533            Some(json_str) if json_str.is_empty() => None,
534            Some(json_str) => PartitionExpr::from_json_str(json_str).with_context(|_| {
535                InvalidPartitionExprSnafu {
536                    expr: json_str.clone(),
537                }
538            })?,
539        };
540
541        let output_files = sst_infos
542            .iter()
543            .map(|sst_info| {
544                let pk_range = sst_info
545                    .file_metadata
546                    .as_ref()
547                    .and_then(|meta| extract_primary_key_range(meta, &region_metadata));
548                let (primary_key_min, primary_key_max) = match pk_range {
549                    Some((min, max)) => (Some(min), Some(max)),
550                    None => (None, None),
551                };
552
553                FileMeta {
554                    region_id,
555                    file_id: sst_info.file_id,
556                    time_range: sst_info.time_range,
557                    level: output.output_level,
558                    file_size: sst_info.file_size,
559                    max_row_group_uncompressed_size: sst_info.max_row_group_uncompressed_size,
560                    available_indexes: sst_info.index_metadata.build_available_indexes(),
561                    indexes: sst_info.index_metadata.build_indexes(),
562                    index_file_size: sst_info.index_metadata.file_size,
563                    index_version: 0,
564                    num_rows: sst_info.num_rows as u64,
565                    num_row_groups: sst_info.num_row_groups,
566                    sequence: sequence_metadata.file_sequence,
567                    partition_expr: partition_expr.clone(),
568                    num_series: sst_info.num_series,
569                    primary_key_min,
570                    primary_key_max,
571                    preserve_row_sequence: sequence_metadata.exact_sequence_trusted,
572                }
573            })
574            .collect::<Vec<_>>();
575        let output_file_names = output_files.iter().map(|f| f.file_id.to_string()).join(",");
576        info!(
577            "Region {} compaction inputs: [{}], outputs: [{}], flat_format: {}, metrics: {:?}",
578            region_id, input_file_names, output_file_names, flat_format, metrics
579        );
580        metrics.observe();
581        Ok((output_files, sst_infos.into_iter().collect()))
582    }
583}
584
585/// DefaultCompactor is the default implementation of Compactor.
586///
587/// It is parameterized by an [`SstMerger`] to allow injecting mock
588/// implementations in tests.
589pub struct DefaultCompactor<M = DefaultSstMerger> {
590    merger: M,
591    cancel_handle: Arc<CancellationHandle>,
592    uncommitted: Option<UncommittedSsts>,
593}
594
595#[cfg(test)]
596impl<M: SstMerger> DefaultCompactor<M> {
597    pub fn with_merger(merger: M) -> Self {
598        Self {
599            merger,
600            cancel_handle: Arc::new(CancellationHandle::default()),
601            uncommitted: None,
602        }
603    }
604}
605
606impl DefaultCompactor {
607    /// Creates a new `DefaultCompactor` with the given cancel handle and no
608    /// uncommitted SSTs tracking.
609    ///
610    /// This is the public entry point for external crates that want a
611    /// cancellable compactor without opting into uncommitted-SST tracking.
612    pub fn new_with_cancel_handle(cancel_handle: Arc<CancellationHandle>) -> Self {
613        Self {
614            merger: DefaultSstMerger,
615            cancel_handle,
616            uncommitted: None,
617        }
618    }
619
620    pub(crate) fn with_cancel_handle(
621        cancel_handle: Arc<CancellationHandle>,
622        uncommitted: UncommittedSsts,
623    ) -> Self {
624        Self {
625            merger: DefaultSstMerger,
626            cancel_handle,
627            uncommitted: Some(uncommitted),
628        }
629    }
630}
631
632#[async_trait::async_trait]
633impl<M: SstMerger> Compactor for DefaultCompactor<M>
634where
635    M: Clone,
636{
637    async fn merge_ssts(
638        &self,
639        compaction_region: &CompactionRegion,
640        mut picker_output: PickerOutput,
641    ) -> Result<MergeOutput> {
642        let internal_parallelism = compaction_region.max_parallelism.max(1);
643        let compaction_time_window = picker_output.time_window_size;
644        let region_id = compaction_region.region_id;
645
646        // Build tasks along with their input file metas so we can track which
647        // inputs correspond to each task.
648        let mut tasks: Vec<(Vec<FileMeta>, _)> = Vec::with_capacity(picker_output.outputs.len());
649
650        for output in picker_output.outputs.drain(..) {
651            let inputs_to_remove: Vec<_> =
652                output.inputs.iter().map(|f| f.meta_ref().clone()).collect();
653            let write_opts = WriteOptions {
654                write_buffer_size: compaction_region.engine_config.sst_write_buffer_size,
655                max_file_size: picker_output.max_file_size,
656                row_group_size: compaction_region.region_options.row_group_size(),
657                float_field_encoding: compaction_region.region_options.float_field_encoding,
658            };
659            let merger = self.merger.clone();
660            let compaction_region = compaction_region.clone();
661            let uncommitted = self.uncommitted.clone();
662            let fut = async move {
663                let result = merger
664                    .merge_single_output(compaction_region, output, write_opts)
665                    .await;
666                if let (Some(uncommitted), Ok((_, infos))) = (&uncommitted, &result) {
667                    uncommitted.track(infos);
668                }
669                result
670            };
671            tasks.push((inputs_to_remove, fut));
672        }
673
674        let hook: Option<RegionHookRef> = compaction_region.plugins.get();
675        let mut output_files = Vec::with_capacity(tasks.len());
676        let mut all_sst_infos: Vec<SstInfo> = Vec::new();
677        let mut compacted_inputs = Vec::with_capacity(
678            tasks.iter().map(|(inputs, _)| inputs.len()).sum::<usize>()
679                + picker_output.expired_ssts.len(),
680        );
681
682        while !tasks.is_empty() {
683            let mut chunk: Vec<(Vec<FileMeta>, _)> = Vec::with_capacity(internal_parallelism);
684            for _ in 0..internal_parallelism {
685                if let Some(task) = tasks.pop() {
686                    chunk.push(task);
687                }
688            }
689            let mut spawned: Vec<_> = chunk
690                .into_iter()
691                .map(|(inputs, fut)| {
692                    let handle = common_runtime::spawn_compact(fut);
693                    (inputs, handle)
694                })
695                .collect();
696
697            while let Some((inputs, mut handle)) = spawned.pop() {
698                let abort_handle = handle.abort_handle();
699                match CancellableFuture::new(&mut handle, self.cancel_handle.clone()).await {
700                    Ok(Ok(Ok((files, infos)))) => {
701                        output_files.extend(files);
702                        if hook.is_some() {
703                            all_sst_infos.extend(infos);
704                        }
705                        compacted_inputs.extend(inputs);
706                    }
707                    Ok(Ok(Err(e))) => {
708                        warn!(
709                            e; "Failed to merge compaction output for region: {}, inputs: [{}]",
710                            region_id,
711                            inputs.iter().map(|f| f.file_id.to_string()).join(",")
712                        );
713                    }
714                    Ok(Err(e)) => {
715                        warn!(
716                            "Region {} compaction task join error for inputs: [{}], skipping: {}",
717                            region_id,
718                            inputs.iter().map(|f| f.file_id.to_string()).join(","),
719                            e
720                        );
721                        // If the cancel handle is cancelled,
722                        // cancel the remaining tasks before returns the error.
723                        if self.cancel_handle.is_cancelled() {
724                            abort_handle.abort();
725                            for (_, handle) in &spawned {
726                                handle.abort();
727                            }
728                            for (_, handle) in spawned {
729                                let _ = handle.await;
730                            }
731                        }
732                        return Err(e).context(error::JoinSnafu);
733                    }
734                    Err(_) => {
735                        debug!(
736                            "Compaction merge cancelled for region: {}, aborting remaining {} spawned tasks",
737                            region_id,
738                            spawned.len(),
739                        );
740                        abort_handle.abort();
741                        for (_, handle) in &spawned {
742                            handle.abort();
743                        }
744                        let _ = handle.await;
745                        for (_, handle) in spawned {
746                            let _ = handle.await;
747                        }
748                        break;
749                    }
750                }
751            }
752
753            if self.cancel_handle.is_cancelled() {
754                info!("Compaction merge cancelled for region: {}", region_id);
755                break;
756            }
757        }
758
759        // Include expired SSTs in removals — these don't depend on merge success.
760        compacted_inputs.extend(
761            picker_output
762                .expired_ssts
763                .iter()
764                .map(|f| f.meta_ref().clone()),
765        );
766
767        Ok(MergeOutput {
768            files_to_add: output_files,
769            files_to_remove: compacted_inputs,
770            compaction_time_window: Some(compaction_time_window),
771            sst_infos: all_sst_infos,
772        })
773    }
774
775    async fn update_manifest(
776        &self,
777        compaction_region: &CompactionRegion,
778        merge_output: MergeOutput,
779    ) -> Result<(RegionEdit, ManifestVersion)> {
780        // Write region edit to manifest.
781        let edit = RegionEdit {
782            files_to_add: merge_output.files_to_add,
783            files_to_remove: merge_output.files_to_remove,
784            // Use current timestamp as the edit timestamp.
785            timestamp_ms: Some(chrono::Utc::now().timestamp_millis()),
786            compaction_time_window: merge_output
787                .compaction_time_window
788                .map(|seconds| Duration::from_secs(seconds as u64)),
789            flushed_entry_id: None,
790            flushed_sequence: None,
791            committed_sequence: None,
792        };
793
794        let action_list = RegionMetaActionList::with_action(RegionMetaAction::Edit(edit.clone()));
795        // TODO: We might leak files if we fail to update manifest. We can add a cleanup task to remove them later.
796        let manifest_version = compaction_region
797            .manifest_ctx
798            .update_manifest_for_compaction(action_list)
799            .await?;
800
801        Ok((edit, manifest_version))
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use std::sync::atomic::{AtomicUsize, Ordering};
808    use std::sync::{Arc, Mutex};
809    use std::time::Duration;
810
811    use store_api::storage::{FileId, RegionId};
812    use tokio::time::sleep;
813
814    use super::{DefaultCompactor, *};
815    use crate::cache::CacheManager;
816    use crate::compaction::picker::PickerOutput;
817    use crate::error::Result;
818    use crate::sst::file::FileHandle;
819    use crate::sst::file_purger::NoopFilePurger;
820    use crate::sst::version::SstVersion;
821    use crate::test_util::memtable_util::metadata_for_test;
822    use crate::test_util::scheduler_util::SchedulerEnv;
823
824    fn dummy_file_meta() -> FileMeta {
825        FileMeta {
826            region_id: RegionId::new(1, 1),
827            file_id: FileId::random(),
828            file_size: 100,
829            ..Default::default()
830        }
831    }
832
833    fn new_file_handle(meta: FileMeta) -> FileHandle {
834        FileHandle::new(meta, Arc::new(NoopFilePurger))
835    }
836
837    #[test]
838    fn test_effective_target_trust_and_max_sequence() {
839        let target = RegionId::new(1, 1);
840        let local_trusted = new_file_handle(FileMeta {
841            region_id: target,
842            sequence: NonZeroU64::new(3),
843            preserve_row_sequence: true,
844            ..dummy_file_meta()
845        });
846        let foreign_untrusted_marker = new_file_handle(FileMeta {
847            region_id: RegionId::new(1, 2),
848            sequence: NonZeroU64::new(9),
849            preserve_row_sequence: false,
850            ..dummy_file_meta()
851        });
852        let local_untrusted = new_file_handle(FileMeta {
853            region_id: target,
854            sequence: NonZeroU64::new(11),
855            preserve_row_sequence: false,
856            ..dummy_file_meta()
857        });
858        assert!(local_trusted.is_effective_target_sequence_trusted(target));
859        assert!(foreign_untrusted_marker.is_effective_target_sequence_trusted(target));
860        assert!(!local_untrusted.is_effective_target_sequence_trusted(target));
861        assert_eq!(
862            NonZeroU64::new(9),
863            known_max_input_sequence(&[local_trusted, foreign_untrusted_marker])
864        );
865    }
866
867    #[test]
868    fn test_known_max_input_sequence() {
869        let meta_with = |sequence| FileMeta {
870            sequence,
871            ..dummy_file_meta()
872        };
873
874        let inputs = vec![
875            new_file_handle(meta_with(NonZeroU64::new(3))),
876            new_file_handle(meta_with(NonZeroU64::new(9))),
877            new_file_handle(meta_with(NonZeroU64::new(5))),
878        ];
879        assert_eq!(NonZeroU64::new(9), known_max_input_sequence(&inputs));
880
881        let inputs = vec![
882            new_file_handle(meta_with(NonZeroU64::new(9))),
883            new_file_handle(meta_with(None)),
884        ];
885        assert_eq!(None, known_max_input_sequence(&inputs));
886
887        assert_eq!(None, known_max_input_sequence(&[]));
888    }
889
890    #[tokio::test]
891    async fn test_output_sequence_metadata_preserves_trust_and_unknown_bounds() {
892        let mut region = new_test_compaction_region().await;
893        let local = region.region_id;
894        let foreign = RegionId::new(1, 2);
895        let file = |region_id, sequence: Option<u64>, preserve_row_sequence| {
896            new_file_handle(FileMeta {
897                region_id,
898                sequence: sequence.and_then(NonZeroU64::new),
899                preserve_row_sequence,
900                ..dummy_file_meta()
901            })
902        };
903        // Bounds are independent of row trust: known, mixed trust, unknown in
904        // either sequence domain, empty inputs, and the largest possible bound.
905        let cases = [
906            (
907                vec![file(local, Some(3), true), file(foreign, Some(9), false)],
908                Some(9),
909                true,
910            ),
911            (
912                vec![file(local, Some(3), true), file(local, Some(6), false)],
913                Some(6),
914                false,
915            ),
916            (
917                vec![file(foreign, Some(9), false), file(foreign, Some(11), true)],
918                Some(11),
919                true,
920            ),
921            (
922                vec![file(local, Some(3), true), file(local, None, true)],
923                None,
924                true,
925            ),
926            (
927                vec![file(local, None, false), file(local, Some(6), true)],
928                None,
929                false,
930            ),
931            (
932                vec![file(local, Some(3), true), file(foreign, None, true)],
933                None,
934                false,
935            ),
936            (vec![], None, true),
937            (
938                vec![file(local, Some(u64::MAX), false)],
939                Some(u64::MAX),
940                false,
941            ),
942        ];
943        for committed_sequence in [100, 101] {
944            region
945                .manifest_ctx
946                .manifest_manager
947                .write()
948                .await
949                .update(
950                    RegionMetaActionList::with_action(RegionMetaAction::Edit(RegionEdit {
951                        committed_sequence: Some(committed_sequence),
952                        files_to_add: vec![],
953                        files_to_remove: vec![],
954                        timestamp_ms: None,
955                        compaction_time_window: None,
956                        flushed_entry_id: None,
957                        flushed_sequence: None,
958                    })),
959                    false,
960                )
961                .await
962                .unwrap();
963            for preserve in [false, true] {
964                region.region_options.preserve_row_sequence = preserve;
965                for (inputs, known_bound, inputs_trusted) in &cases {
966                    let metadata = output_sequence_metadata(&region, inputs);
967                    let trusted = preserve && *inputs_trusted;
968                    assert_eq!(trusted, metadata.exact_sequence_trusted);
969                    assert_eq!(
970                        known_bound.and_then(NonZeroU64::new),
971                        metadata.file_sequence
972                    );
973                }
974            }
975        }
976    }
977
978    /// Independent outputs must be safe in either visibility order, including when
979    /// only one output succeeds. Merely committing both outputs together is not a
980    /// substitute for preserving version order across their separate merge streams.
981    #[rstest::rstest]
982    #[case(true)]
983    #[case(false)]
984    #[tokio::test]
985    async fn test_independent_outputs_preserve_delete_in_every_visibility_state(
986        #[case] flat_format: bool,
987        #[values(false, true)] unknown_put_bound: bool,
988    ) {
989        use datatypes::arrow::array::AsArray;
990        use datatypes::arrow::datatypes::TimestampMillisecondType;
991        use store_api::region_engine::RegionEngine;
992        use store_api::region_request::RegionRequest;
993
994        use crate::compaction::CompactionOutput;
995        use crate::compaction::compactor::{CompactionRegion, Compactor, DefaultCompactor};
996        use crate::compaction::picker::PickerOutput;
997        use crate::compaction::reader::CompactionSstReaderBuilder;
998        use crate::engine::compaction_test::{delete_and_flush, put_and_flush};
999        use crate::region::options::MergeMode;
1000        use crate::sst::file::FileHandle;
1001        use crate::sst::file_purger::NoopFilePurger;
1002        use crate::test_util::{CreateRequestBuilder, TestEnv, rows_schema};
1003
1004        let mut env = TestEnv::new().await;
1005        let region_id = RegionId::new(1, 1);
1006        let config = MitoConfig {
1007            default_flat_format: flat_format,
1008            min_compaction_interval: Duration::from_secs(3600),
1009            ..Default::default()
1010        };
1011        let engine = env.create_engine(config).await;
1012        let request = CreateRequestBuilder::new().build();
1013        let columns = rows_schema(&request);
1014        engine
1015            .handle_request(region_id, RegionRequest::Create(request))
1016            .await
1017            .unwrap();
1018        put_and_flush(&engine, region_id, &columns, 0..1).await;
1019        delete_and_flush(&engine, region_id, &columns, 0..1).await;
1020        put_and_flush(&engine, region_id, &columns, 10..11).await;
1021        let region = engine.get_region(region_id).unwrap();
1022        let version = region.version();
1023        let mut files: Vec<_> = version
1024            .ssts
1025            .levels()
1026            .iter()
1027            .flat_map(|level| level.files())
1028            .cloned()
1029            .collect();
1030        assert_eq!(3, files.len());
1031        files.sort_unstable_by_key(|file| file.meta_ref().sequence);
1032        if unknown_put_bound {
1033            // Model a legacy SST whose physical rows are readable but whose
1034            // file-level boundary is unknown. Merging cannot invent that bound.
1035            let mut meta = files[0].meta_ref().clone();
1036            meta.sequence = None;
1037            files[0] = FileHandle::new(meta, Arc::new(NoopFilePurger));
1038        }
1039        let compaction_region = CompactionRegion {
1040            region_id,
1041            region_options: version.options.clone(),
1042            engine_config: Arc::new(MitoConfig {
1043                default_flat_format: flat_format,
1044                ..Default::default()
1045            }),
1046            region_metadata: version.metadata.clone(),
1047            cache_manager: engine.cache_manager(),
1048            access_layer: region.access_layer.clone(),
1049            manifest_ctx: region.manifest_ctx.clone(),
1050            current_version: version.into(),
1051            file_purger: None,
1052            ttl: None,
1053            max_parallelism: 2,
1054            plugins: common_base::Plugins::new(),
1055        };
1056        let puts = vec![files[0].clone(), files[2].clone()];
1057        let deletes = vec![files[1].clone()];
1058        let picker_output = PickerOutput {
1059            outputs: [puts.clone(), deletes.clone()]
1060                .into_iter()
1061                .map(|inputs| CompactionOutput {
1062                    output_level: 1,
1063                    inputs,
1064                    filter_deleted: false,
1065                    output_time_range: None,
1066                })
1067                .collect(),
1068            expired_ssts: vec![],
1069            time_window_size: 3600,
1070            max_file_size: None,
1071        };
1072        let merged = DefaultCompactor::with_merger(DefaultSstMerger)
1073            .merge_ssts(&compaction_region, picker_output)
1074            .await
1075            .unwrap();
1076        assert_eq!(2, merged.files_to_add.len());
1077        let outputs: Vec<_> = merged
1078            .files_to_add
1079            .into_iter()
1080            .map(|meta| FileHandle::new(meta, Arc::new(NoopFilePurger)))
1081            .collect();
1082        let put_output = outputs
1083            .iter()
1084            .find(|file| file.meta_ref().num_rows == 2)
1085            .unwrap();
1086        let delete_output = outputs
1087            .iter()
1088            .find(|file| file.meta_ref().num_rows == 1)
1089            .unwrap();
1090        assert_eq!(
1091            if unknown_put_bound {
1092                None
1093            } else {
1094                NonZeroU64::new(3)
1095            },
1096            put_output.meta_ref().sequence,
1097        );
1098        assert_eq!(NonZeroU64::new(2), delete_output.meta_ref().sequence);
1099        assert!(
1100            outputs
1101                .iter()
1102                .all(|file| !file.meta_ref().preserve_row_sequence)
1103        );
1104
1105        // Cover the original snapshot, each partial success/cancellation state,
1106        // and both completed outputs using real persisted SSTs and merge readers.
1107        for (put_done, delete_done) in [(false, false), (true, false), (false, true), (true, true)]
1108        {
1109            let mut visible = if put_done {
1110                vec![put_output.clone()]
1111            } else {
1112                puts.clone()
1113            };
1114            visible.extend(if delete_done {
1115                vec![delete_output.clone()]
1116            } else {
1117                deletes.clone()
1118            });
1119            let mut reader = CompactionSstReaderBuilder {
1120                metadata: compaction_region.region_metadata.clone(),
1121                sst_layer: region.access_layer.clone(),
1122                cache: engine.cache_manager(),
1123                inputs: &visible,
1124                append_mode: false,
1125                filter_deleted: true,
1126                time_range: None,
1127                merge_mode: MergeMode::LastRow,
1128            }
1129            .build_flat_sst_reader()
1130            .await
1131            .unwrap();
1132            let mut timestamps = Vec::new();
1133            while let Some(batch) = reader.next_batch().await.unwrap() {
1134                timestamps.extend(
1135                    batch
1136                        .column_by_name("ts")
1137                        .unwrap()
1138                        .as_primitive::<TimestampMillisecondType>()
1139                        .values()
1140                        .iter()
1141                        .copied(),
1142                );
1143            }
1144            assert_eq!(
1145                vec![10_000],
1146                timestamps,
1147                "put_done={put_done}, delete_done={delete_done}"
1148            );
1149        }
1150    }
1151
1152    /// Build a minimal [`CompactionRegion`] suitable for tests where the
1153    /// [`SstMerger`] is mocked and never touches the access layer.
1154    async fn new_test_compaction_region() -> CompactionRegion {
1155        let env = SchedulerEnv::new().await;
1156        let metadata = metadata_for_test();
1157        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
1158        CompactionRegion {
1159            region_id: RegionId::new(1, 1),
1160            region_options: RegionOptions::default(),
1161            engine_config: Arc::new(MitoConfig::default()),
1162            region_metadata: metadata.clone(),
1163            cache_manager: Arc::new(CacheManager::default()),
1164            access_layer: env.access_layer.clone(),
1165            manifest_ctx,
1166            current_version: CompactionVersion {
1167                metadata: metadata.clone(),
1168                options: RegionOptions::default(),
1169                ssts: Arc::new(SstVersion::new(metadata)),
1170                memtable_min_sequence: None,
1171                compaction_time_window: None,
1172            },
1173            file_purger: None,
1174            ttl: None,
1175            max_parallelism: 1,
1176            plugins: Plugins::new(),
1177        }
1178    }
1179
1180    /// An [`SstMerger`] that returns pre-configured results per call index.
1181    ///
1182    /// Call 0 gets `results[0]`, call 1 gets `results[1]`, etc.
1183    #[derive(Clone)]
1184    struct MockMerger {
1185        results: Arc<Mutex<Vec<Result<Vec<FileMeta>>>>>,
1186        call_idx: Arc<AtomicUsize>,
1187    }
1188
1189    impl MockMerger {
1190        fn new(results: Vec<Result<Vec<FileMeta>>>) -> Self {
1191            Self {
1192                results: Arc::new(Mutex::new(results)),
1193                call_idx: Arc::new(AtomicUsize::new(0)),
1194            }
1195        }
1196    }
1197
1198    #[async_trait::async_trait]
1199    impl SstMerger for MockMerger {
1200        async fn merge_single_output(
1201            &self,
1202            _compaction_region: CompactionRegion,
1203            _output: CompactionOutput,
1204            _write_opts: WriteOptions,
1205        ) -> Result<(Vec<FileMeta>, Vec<SstInfo>)> {
1206            let idx = self.call_idx.fetch_add(1, Ordering::SeqCst);
1207            match self.results.lock().unwrap().get(idx) {
1208                Some(Ok(files)) => Ok((files.clone(), Vec::new())),
1209                Some(Err(_)) => error::InvalidMetaSnafu {
1210                    reason: format!("simulated failure at index {idx}"),
1211                }
1212                .fail(),
1213                None => panic!("MockMerger: no result configured for call index {idx}"),
1214            }
1215        }
1216    }
1217
1218    #[tokio::test]
1219    async fn test_partial_merge_failure_collects_only_successful_outputs() {
1220        common_telemetry::init_default_ut_logging();
1221
1222        let compaction_region = new_test_compaction_region().await;
1223
1224        // Prepare 3 compaction outputs: output 0 and 2 succeed, output 1 fails.
1225        let input_meta_0 = dummy_file_meta();
1226        let input_meta_1 = dummy_file_meta();
1227        let input_meta_2 = dummy_file_meta();
1228
1229        let output_meta_0 = vec![dummy_file_meta()];
1230        let output_meta_2 = vec![dummy_file_meta(), dummy_file_meta()];
1231
1232        let merger = MockMerger::new(vec![
1233            Ok(output_meta_0.clone()),
1234            Err(error::InvalidMetaSnafu {
1235                reason: "boom".to_string(),
1236            }
1237            .build()),
1238            Ok(output_meta_2.clone()),
1239        ]);
1240        let compactor = DefaultCompactor::with_merger(merger);
1241
1242        let picker_output = PickerOutput {
1243            outputs: vec![
1244                CompactionOutput {
1245                    output_level: 1,
1246                    inputs: vec![new_file_handle(input_meta_0.clone())],
1247                    filter_deleted: false,
1248                    output_time_range: None,
1249                },
1250                CompactionOutput {
1251                    output_level: 1,
1252                    inputs: vec![new_file_handle(input_meta_1.clone())],
1253                    filter_deleted: false,
1254                    output_time_range: None,
1255                },
1256                CompactionOutput {
1257                    output_level: 1,
1258                    inputs: vec![new_file_handle(input_meta_2.clone())],
1259                    filter_deleted: false,
1260                    output_time_range: None,
1261                },
1262            ],
1263            expired_ssts: vec![],
1264            time_window_size: 3600,
1265            max_file_size: None,
1266        };
1267
1268        let merge_output = compactor
1269            .merge_ssts(&compaction_region, picker_output)
1270            .await
1271            .unwrap();
1272
1273        // Outputs 0 and 2 succeeded (1 + 2 = 3 files added).
1274        assert_eq!(merge_output.files_to_add.len(), 3);
1275        // Only inputs from successful merges should be removed.
1276        assert_eq!(merge_output.files_to_remove.len(), 2);
1277
1278        let removed_ids: Vec<_> = merge_output
1279            .files_to_remove
1280            .iter()
1281            .map(|f| f.file_id)
1282            .collect();
1283        assert!(removed_ids.contains(&input_meta_0.file_id));
1284        assert!(removed_ids.contains(&input_meta_2.file_id));
1285        // The failed output's input must NOT be removed.
1286        assert!(!removed_ids.contains(&input_meta_1.file_id));
1287    }
1288
1289    #[tokio::test]
1290    async fn test_all_outputs_succeed() {
1291        common_telemetry::init_default_ut_logging();
1292
1293        let compaction_region = new_test_compaction_region().await;
1294        let input_meta = dummy_file_meta();
1295        let output_meta = vec![dummy_file_meta()];
1296
1297        let merger = MockMerger::new(vec![Ok(output_meta.clone())]);
1298        let compactor = DefaultCompactor::with_merger(merger);
1299
1300        let picker_output = PickerOutput {
1301            outputs: vec![CompactionOutput {
1302                output_level: 1,
1303                inputs: vec![new_file_handle(input_meta.clone())],
1304                filter_deleted: false,
1305                output_time_range: None,
1306            }],
1307            expired_ssts: vec![],
1308            time_window_size: 3600,
1309            max_file_size: None,
1310        };
1311
1312        let merge_output = compactor
1313            .merge_ssts(&compaction_region, picker_output)
1314            .await
1315            .unwrap();
1316
1317        assert_eq!(merge_output.files_to_add.len(), 1);
1318        assert_eq!(merge_output.files_to_add[0].file_id, output_meta[0].file_id);
1319        assert_eq!(merge_output.files_to_remove.len(), 1);
1320        assert_eq!(merge_output.files_to_remove[0].file_id, input_meta.file_id);
1321    }
1322
1323    #[tokio::test]
1324    async fn test_expired_ssts_always_removed() {
1325        common_telemetry::init_default_ut_logging();
1326
1327        let compaction_region = new_test_compaction_region().await;
1328        let input_meta = dummy_file_meta();
1329        let expired_meta = dummy_file_meta();
1330
1331        // The single merge output fails, but expired SSTs should still be removed.
1332        let merger = MockMerger::new(vec![Err(error::InvalidMetaSnafu {
1333            reason: "fail".to_string(),
1334        }
1335        .build())]);
1336        let compactor = DefaultCompactor::with_merger(merger);
1337
1338        let picker_output = PickerOutput {
1339            outputs: vec![CompactionOutput {
1340                output_level: 1,
1341                inputs: vec![new_file_handle(input_meta.clone())],
1342                filter_deleted: false,
1343                output_time_range: None,
1344            }],
1345            expired_ssts: vec![new_file_handle(expired_meta.clone())],
1346            time_window_size: 3600,
1347            max_file_size: None,
1348        };
1349
1350        let merge_output = compactor
1351            .merge_ssts(&compaction_region, picker_output)
1352            .await
1353            .unwrap();
1354
1355        // No files added (merge failed).
1356        assert!(merge_output.files_to_add.is_empty());
1357        // Only the expired SST should be in files_to_remove (not the failed merge's input).
1358        assert_eq!(merge_output.files_to_remove.len(), 1);
1359        assert_eq!(
1360            merge_output.files_to_remove[0].file_id,
1361            expired_meta.file_id
1362        );
1363    }
1364
1365    #[derive(Clone)]
1366    struct BlockingMerger {
1367        call_idx: Arc<AtomicUsize>,
1368    }
1369
1370    #[async_trait::async_trait]
1371    impl SstMerger for BlockingMerger {
1372        async fn merge_single_output(
1373            &self,
1374            _compaction_region: CompactionRegion,
1375            _output: CompactionOutput,
1376            _write_opts: WriteOptions,
1377        ) -> Result<(Vec<FileMeta>, Vec<SstInfo>)> {
1378            self.call_idx.fetch_add(1, Ordering::SeqCst);
1379            std::future::pending().await
1380        }
1381    }
1382
1383    #[tokio::test(flavor = "multi_thread")]
1384    async fn test_merge_ssts_cancels_spawned_tasks() {
1385        common_telemetry::init_default_ut_logging();
1386
1387        let mut compaction_region = new_test_compaction_region().await;
1388        compaction_region.max_parallelism = 2;
1389
1390        let cancel_handle = Arc::new(CancellationHandle::default());
1391        let call_idx = Arc::new(AtomicUsize::new(0));
1392        let compactor = DefaultCompactor {
1393            merger: BlockingMerger {
1394                call_idx: call_idx.clone(),
1395            },
1396            cancel_handle: cancel_handle.clone(),
1397            uncommitted: None,
1398        };
1399
1400        let picker_output = PickerOutput {
1401            outputs: vec![
1402                CompactionOutput {
1403                    output_level: 1,
1404                    inputs: vec![new_file_handle(dummy_file_meta())],
1405                    filter_deleted: false,
1406                    output_time_range: None,
1407                },
1408                CompactionOutput {
1409                    output_level: 1,
1410                    inputs: vec![new_file_handle(dummy_file_meta())],
1411                    filter_deleted: false,
1412                    output_time_range: None,
1413                },
1414                CompactionOutput {
1415                    output_level: 1,
1416                    inputs: vec![new_file_handle(dummy_file_meta())],
1417                    filter_deleted: false,
1418                    output_time_range: None,
1419                },
1420            ],
1421            expired_ssts: vec![],
1422            time_window_size: 3600,
1423            max_file_size: None,
1424        };
1425
1426        let task = tokio::spawn(async move {
1427            compactor
1428                .merge_ssts(&compaction_region, picker_output)
1429                .await
1430        });
1431
1432        sleep(Duration::from_millis(100)).await;
1433        cancel_handle.cancel();
1434
1435        let merge_output = task
1436            .await
1437            .expect("merge_ssts should stop after cancellation")
1438            .unwrap();
1439
1440        let started = call_idx.load(Ordering::SeqCst);
1441
1442        assert!(merge_output.files_to_add.is_empty());
1443        assert!(merge_output.files_to_remove.is_empty());
1444        assert_eq!(started, 2);
1445    }
1446}