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