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