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