Skip to main content

mito2/compaction/scheduler/
planning.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::fmt;
16use std::future::Future;
17use std::sync::Arc;
18use std::time::Instant;
19
20use api::v1::region::compact_request;
21use common_base::Plugins;
22use common_meta::key::SchemaMetadataManagerRef;
23use common_telemetry::{debug, error, info, warn};
24use common_time::TimeToLive;
25use common_time::range::TimestampRange;
26use futures::FutureExt;
27use snafu::ResultExt;
28use store_api::storage::RegionId;
29use tokio::sync::mpsc::{self, Sender};
30
31use crate::access_layer::AccessLayerRef;
32use crate::cache::CacheManagerRef;
33use crate::compaction::compactor::{CompactionRegion, CompactionVersion};
34use crate::compaction::picker::{PickerOutput, new_picker};
35use crate::compaction::scheduler::state::{CompactingFiles, CompactionExecution, CompactionPhase};
36use crate::compaction::scheduler::{CompactionScheduler, CompactionTransition};
37use crate::compaction::unit::CompactionUnit;
38use crate::compaction::{CompactionOutput, find_dynamic_options, format_panic_reason};
39use crate::config::MitoConfig;
40use crate::error::{CompactRegionSnafu, Error, RemoteCompactionSnafu, Result, UnexpectedSnafu};
41use crate::metrics::{
42    COMPACTION_MEMORY_REJECTED, COMPACTION_STAGE_ELAPSED, INFLIGHT_COMPACTION_COUNT,
43};
44use crate::region::ManifestContextRef;
45use crate::region::options::RegionOptions;
46use crate::request::{BackgroundNotify, OutputTx, WorkerRequest, WorkerRequestWithTime};
47use crate::schedule::remote_job_scheduler::{
48    CompactionJob, DefaultNotifier, RemoteJob, RemoteJobSchedulerRef,
49};
50use crate::sst::file::FileHandle;
51use crate::sst::version::SstVersion;
52use crate::worker::WorkerListener;
53
54/// Region compaction request.
55pub struct CompactionRequest {
56    pub(crate) engine_config: Arc<MitoConfig>,
57    pub(crate) current_version: CompactionVersion,
58    pub(crate) access_layer: AccessLayerRef,
59    /// Sender to send notification to the region worker.
60    pub(crate) request_sender: mpsc::Sender<WorkerRequestWithTime>,
61    /// Start time of compaction task.
62    pub(crate) start_time: Instant,
63    pub(crate) cache_manager: CacheManagerRef,
64    pub(crate) manifest_ctx: ManifestContextRef,
65    pub(crate) listener: WorkerListener,
66    pub(crate) schema_metadata_manager: SchemaMetadataManagerRef,
67    pub(crate) max_parallelism: usize,
68}
69
70impl CompactionRequest {
71    pub(crate) fn region_id(&self) -> RegionId {
72        self.current_version.metadata.region_id
73    }
74}
75
76/// Result returned to the worker after background compaction planning.
77pub(crate) enum CompactionPlanningResult {
78    Prepared(PreparedCompaction),
79    NoPlan,
80    Error(Arc<Error>),
81}
82
83impl fmt::Debug for CompactionPlanningResult {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Prepared(prepared) => f
87                .debug_tuple("Prepared")
88                .field(&prepared.compaction_region.region_id)
89                .finish(),
90            Self::NoPlan => f.write_str("NoPlan"),
91            Self::Error(err) => f.debug_tuple("Error").field(err).finish(),
92        }
93    }
94}
95
96/// Pure planning completion sent back to the owning region worker.
97#[derive(Debug)]
98pub(crate) struct CompactionPickFinished {
99    pub(crate) region_id: RegionId,
100    pub(crate) plan_id: u64,
101    pub(crate) result: CompactionPlanningResult,
102}
103
104pub(crate) struct PreparedCompaction {
105    pub(super) compaction_region: CompactionRegion,
106    pub(super) picker_output: PickerOutput,
107    start_time: Instant,
108    ttl: TimeToLive,
109}
110
111impl CompactionScheduler {
112    pub(super) fn dispatch_compaction_planning(
113        &self,
114        plan_id: u64,
115        request: CompactionRequest,
116        options: compact_request::Options,
117        time_range: Option<TimestampRange>,
118    ) {
119        let plugins = self.plugins.clone();
120        let max_background_compactions = self.engine_config.max_background_compactions;
121        common_runtime::spawn_compact(async move {
122            let region_id = request.region_id();
123            let request_sender = request.request_sender.clone();
124            let planning = Self::prepare_compaction(
125                request,
126                options,
127                plugins,
128                max_background_compactions,
129                time_range,
130            );
131            Self::notify_planning_result(region_id, plan_id, request_sender, planning).await;
132        });
133    }
134
135    /// Runs the planning future and always sends the planning result back to
136    /// the worker, even if the planning panics.
137    ///
138    /// The worker only leaves the picking phase after it receives the
139    /// `CompactionPickFinished` notification. If a panicked planning task
140    /// swallowed the notification, the region would be stuck in the picking
141    /// phase forever, blocking all future compactions and pending DDLs (e.g.
142    /// entering staging) of the region.
143    pub(super) async fn notify_planning_result(
144        region_id: RegionId,
145        plan_id: u64,
146        request_sender: Sender<WorkerRequestWithTime>,
147        planning: impl Future<Output = CompactionPlanningResult> + Send,
148    ) {
149        // The idiomatic way to handle a panic result.
150        let result = std::panic::AssertUnwindSafe(planning).catch_unwind().await.unwrap_or_else(|payload| {
151            let reason = format_panic_reason(payload.as_ref());
152            CompactionPlanningResult::Error(Arc::new(
153                UnexpectedSnafu {
154                    reason: format!(
155                        "Compaction planning panicked for region {region_id}, plan_id {plan_id}: {reason}"
156                    ),
157                }
158                    .build(),
159            ))
160        });
161        if let CompactionPlanningResult::Error(err) = &result {
162            error!(err; "Compaction planning failed for region {}, plan_id: {}", region_id, plan_id);
163        }
164        let request = WorkerRequestWithTime::new(WorkerRequest::Background {
165            region_id,
166            notify: BackgroundNotify::CompactionPickFinished(CompactionPickFinished {
167                region_id,
168                plan_id,
169                result,
170            }),
171        });
172        if request_sender.send(request).await.is_err() {
173            warn!("Failed to send compaction planning result for region {region_id}");
174        }
175    }
176
177    async fn prepare_compaction(
178        request: CompactionRequest,
179        options: compact_request::Options,
180        plugins: Plugins,
181        max_background_compactions: usize,
182        time_range: Option<TimestampRange>,
183    ) -> CompactionPlanningResult {
184        let region_id = request.region_id();
185        let (dynamic_compaction_opts, ttl) = find_dynamic_options(
186            region_id,
187            &request.current_version.options,
188            &request.schema_metadata_manager,
189        )
190        .await
191        .unwrap_or_else(|e| {
192            warn!(e; "Failed to find dynamic options for region: {}", region_id);
193            (
194                request.current_version.options.compaction.clone(),
195                request.current_version.options.ttl.unwrap_or_default(),
196            )
197        });
198
199        // Avoid queuing serial outputs from a stale snapshot. Repicking after each
200        // batch lets newly flushed L0 files outrank L1 work that has not started.
201        let max_picker_outputs = max_background_compactions.min(request.max_parallelism.max(1));
202        let region_options = RegionOptions {
203            compaction: dynamic_compaction_opts,
204            ..request.current_version.options.clone()
205        };
206        let picker = new_picker(
207            &options,
208            &region_options,
209            Some(max_picker_outputs),
210            time_range,
211        );
212        let region_id = request.region_id();
213        let CompactionRequest {
214            engine_config,
215            current_version,
216            access_layer,
217            request_sender: _,
218            start_time,
219            cache_manager,
220            manifest_ctx,
221            listener,
222            schema_metadata_manager: _,
223            max_parallelism,
224        } = request;
225
226        debug!(
227            "Pick compaction strategy {:?} for region: {}, ttl: {:?}",
228            picker, region_id, ttl
229        );
230
231        let compaction_region = CompactionRegion {
232            region_id,
233            current_version: current_version.clone(),
234            region_options,
235            engine_config: engine_config.clone(),
236            region_metadata: current_version.metadata.clone(),
237            cache_manager: cache_manager.clone(),
238            access_layer: access_layer.clone(),
239            manifest_ctx: manifest_ctx.clone(),
240            file_purger: None,
241            ttl: Some(ttl),
242            max_parallelism,
243            plugins,
244        };
245
246        listener.on_compaction_pick_begin(region_id).await;
247        let _pick_timer = COMPACTION_STAGE_ELAPSED
248            .with_label_values(&["pick"])
249            .start_timer();
250        let picker_output = match picker.pick(&compaction_region).await {
251            Ok(output) => output,
252            Err(err) => return CompactionPlanningResult::Error(Arc::new(err)),
253        };
254
255        let Some(picker_output) = picker_output else {
256            return CompactionPlanningResult::NoPlan;
257        };
258
259        CompactionPlanningResult::Prepared(PreparedCompaction {
260            compaction_region,
261            picker_output,
262            start_time,
263            ttl,
264        })
265    }
266
267    /// Applies a background planning result to the current compaction lifecycle.
268    ///
269    /// # Returns
270    ///
271    /// Reports an automatic follow-up dispatched after Picking, or DDL requests
272    /// released when Picking terminates without creating an execution. The
273    /// owning worker must dispatch returned DDLs immediately because no
274    /// execution callback will follow.
275    pub(super) async fn handle_compaction_pick_finished_inner(
276        &mut self,
277        finished: CompactionPickFinished,
278        manifest_ctx: &ManifestContextRef,
279        schema_metadata_manager: SchemaMetadataManagerRef,
280    ) -> CompactionTransition {
281        let region_id = finished.region_id;
282        let plan_id = finished.plan_id;
283        let Some(status) = self.region_status.get(&region_id) else {
284            return CompactionTransition::NoAction;
285        };
286
287        if !status.is_picking(finished.plan_id) {
288            return CompactionTransition::NoAction;
289        }
290        // Cancellation during Picking is completed by this notification. No
291        // execution callback will follow, so return DDLs released by the fence.
292        if !status.accept_plan(finished.plan_id) {
293            return CompactionTransition::from_pending_ddls(
294                self.finish_compaction_on_cancel(region_id),
295            );
296        }
297
298        match finished.result {
299            CompactionPlanningResult::Prepared(mut prepared) => {
300                let current = status.version_control.current().version;
301                let Some(picker_output) =
302                    refresh_picker_output(prepared.picker_output, &current.ssts)
303                else {
304                    return self
305                        .finish_compaction_planning(
306                            region_id,
307                            None,
308                            manifest_ctx,
309                            schema_metadata_manager,
310                        )
311                        .await;
312                };
313                let Some(files) = CompactingFiles::try_new(&picker_output) else {
314                    return self
315                        .finish_compaction_planning(
316                            region_id,
317                            None,
318                            manifest_ctx,
319                            schema_metadata_manager,
320                        )
321                        .await;
322                };
323                prepared.picker_output = picker_output;
324                let Some(status) = self.region_status.get_mut(&region_id) else {
325                    return CompactionTransition::NoAction;
326                };
327                let waiters = status.take_waiters();
328                match self
329                    .prepare_or_submit_compaction(prepared, files, waiters, plan_id)
330                    .await
331                {
332                    Ok(Some(phase)) => {
333                        if let Some(status) = self.region_status.get_mut(&region_id) {
334                            status.set_phase(phase);
335                        }
336                        self.dispatch_local_units(region_id);
337                        // The execution now owns the terminal transition. Any
338                        // fenced DDLs remain queued until its callback arrives.
339                        self.finish_local_units(region_id, manifest_ctx, schema_metadata_manager)
340                            .await
341                    }
342                    Ok(None) => {
343                        self.finish_compaction_planning(
344                            region_id,
345                            None,
346                            manifest_ctx,
347                            schema_metadata_manager,
348                        )
349                        .await
350                    }
351                    Err(err) => {
352                        self.fail_compaction(region_id, Arc::new(err));
353                        CompactionTransition::NoAction
354                    }
355                }
356            }
357            // These paths never create an execution. Finish the Picking
358            // lifecycle here and release DDLs if no follow-up was scheduled.
359            CompactionPlanningResult::NoPlan => {
360                self.finish_compaction_planning(
361                    region_id,
362                    None,
363                    manifest_ctx,
364                    schema_metadata_manager,
365                )
366                .await
367            }
368            CompactionPlanningResult::Error(err) => {
369                self.finish_compaction_planning(
370                    region_id,
371                    Some(err),
372                    manifest_ctx,
373                    schema_metadata_manager,
374                )
375                .await
376            }
377        }
378    }
379
380    async fn finish_compaction_planning(
381        &mut self,
382        region_id: RegionId,
383        err: Option<Arc<Error>>,
384        manifest_ctx: &ManifestContextRef,
385        schema_metadata_manager: SchemaMetadataManagerRef,
386    ) -> CompactionTransition {
387        let Some(status) = self.region_status.get_mut(&region_id) else {
388            return CompactionTransition::NoAction;
389        };
390        for waiter in status.take_waiters() {
391            if let Some(err) = &err {
392                waiter.send(Err(err.clone()).context(CompactRegionSnafu { region_id }));
393            } else {
394                waiter.send(Ok(0));
395            }
396        }
397
398        if let Some(ddls) = self.take_ready_ddls(region_id) {
399            return CompactionTransition::DdlReady(ddls);
400        }
401        if self.handle_pending_compaction_request(
402            region_id,
403            manifest_ctx,
404            schema_metadata_manager.clone(),
405        ) {
406            return CompactionTransition::NoAction;
407        }
408
409        let Some(status) = self.region_status.get_mut(&region_id) else {
410            return CompactionTransition::NoAction;
411        };
412
413        if status.active.reset_automatic_followup()
414            && self.schedule_automatic_followup(region_id, manifest_ctx, schema_metadata_manager)
415        {
416            return CompactionTransition::AutomaticFollowupScheduled;
417        }
418
419        if let Some(status) = self.region_status.get_mut(&region_id) {
420            status.become_idle();
421        }
422        CompactionTransition::NoAction
423    }
424
425    /// Submits a remote job, or prepares local unit tasks without submitting them.
426    /// The caller dispatches local units after installing the returned phase.
427    async fn prepare_or_submit_compaction(
428        &mut self,
429        prepared: PreparedCompaction,
430        files: CompactingFiles,
431        waiters: Vec<OutputTx>,
432        plan_id: u64,
433    ) -> Result<Option<CompactionPhase>> {
434        let PreparedCompaction {
435            compaction_region,
436            picker_output,
437            start_time,
438            ttl,
439        } = prepared;
440        let region_id = compaction_region.region_id;
441        let dynamic_compaction_opts = &compaction_region.region_options.compaction;
442
443        // If specified to run compaction remotely, we schedule the compaction job remotely.
444        // It will fall back to local compaction if there is no remote job scheduler.
445        let waiters = if dynamic_compaction_opts.remote_compaction() {
446            if let Some(remote_job_scheduler) = &self.plugins.get::<RemoteJobSchedulerRef>() {
447                let execution = CompactionExecution::new(plan_id, files.clone());
448                let remote_compaction_job = CompactionJob {
449                    compaction_region: compaction_region.clone(),
450                    picker_output: picker_output.clone(),
451                    start_time,
452                    waiters,
453                    ttl,
454                };
455
456                let result = remote_job_scheduler
457                    .schedule(
458                        RemoteJob::CompactionJob(remote_compaction_job),
459                        Box::new(DefaultNotifier::new(
460                            self.request_sender.clone(),
461                            execution.clone(),
462                        )),
463                    )
464                    .await;
465
466                match result {
467                    Ok(job_id) => {
468                        info!(
469                            "Scheduled remote compaction job {} for region {}",
470                            job_id, region_id
471                        );
472                        INFLIGHT_COMPACTION_COUNT.inc();
473                        return Ok(Some(CompactionPhase::Remote { execution }));
474                    }
475                    Err(e) => {
476                        if !dynamic_compaction_opts.fallback_to_local() {
477                            error!(e; "Failed to schedule remote compaction job for region {}", region_id);
478                            if let Some(status) = self.region_status.get_mut(&region_id) {
479                                status.extend_waiters(e.waiters);
480                            }
481                            return RemoteCompactionSnafu {
482                                region_id,
483                                job_id: None,
484                                reason: e.reason,
485                            }
486                            .fail();
487                        }
488
489                        error!(e; "Failed to schedule remote compaction job for region {}, fallback to local compaction", region_id);
490                        // An error may be ambiguous after the remote scheduler consumed
491                        // the notifier. Fence a delayed remote callback from the local fallback.
492                        e.waiters
493                    }
494                }
495            } else {
496                debug!(
497                    "Remote compaction is not enabled, fallback to local compaction for region {}",
498                    region_id
499                );
500                waiters
501            }
502        } else {
503            waiters
504        };
505
506        let units = CompactionUnit::from_picker(picker_output);
507        if let Some(status) = self.region_status.get_mut(&region_id) {
508            status.extend_waiters(waiters);
509        }
510        let Some(mut units) = units else {
511            return UnexpectedSnafu {
512                reason: "Compaction plan expires a merge input",
513            }
514            .fail();
515        };
516        units.retain(|unit| {
517            let estimated_bytes = unit.estimated_memory_bytes();
518            let Some(limit_bytes) = self.exceeds_compaction_memory_limit(estimated_bytes) else { return true };
519            COMPACTION_MEMORY_REJECTED
520                .with_label_values(&["oversized"])
521                .inc();
522            warn!(
523                "Skip compaction for region {} because estimated memory {} bytes exceeds compaction memory limit {} bytes",
524                region_id, estimated_bytes, limit_bytes,
525            );
526            false
527        });
528        if units.is_empty() {
529            return Ok(None);
530        }
531        Ok(Some(self.prepare_local_units(
532            compaction_region,
533            units,
534            files,
535        )))
536    }
537
538    fn exceeds_compaction_memory_limit(&self, estimated_bytes: u64) -> Option<u64> {
539        let limit_bytes = self.memory_manager.limit_bytes();
540        if limit_bytes > 0 && estimated_bytes > limit_bytes {
541            Some(limit_bytes)
542        } else {
543            None
544        }
545    }
546}
547
548/// Rebuilds picker output with current SST handles while preserving the picker's grouping.
549///
550/// Picking runs in background on a version snapshot that may be stale by the
551/// time the plan is accepted: a concurrent flush, compaction or index rebuild
552/// can replace a selected file with a new handle carrying updated metadata
553/// (e.g. `index_version`), or remove the file entirely. The handles in the
554/// picker output therefore cannot be used as-is; re-resolving them against the
555/// current version both detects gone files (aborting the plan) and ensures the
556/// execution reads and reserves the up-to-date handle.
557fn refresh_picker_output(output: PickerOutput, current: &SstVersion) -> Option<PickerOutput> {
558    let refresh = |file: FileHandle| {
559        current
560            .file_for_compaction(&file)
561            .filter(|current| !current.is_deleted() && !current.compacting())
562            .cloned()
563    };
564    let outputs = output
565        .outputs
566        .into_iter()
567        .map(|output| {
568            let inputs = output
569                .inputs
570                .into_iter()
571                .map(&refresh)
572                .collect::<Option<Vec<_>>>()?;
573            Some(CompactionOutput { inputs, ..output })
574        })
575        .collect::<Option<Vec<_>>>()?;
576    let expired_ssts = output
577        .expired_ssts
578        .into_iter()
579        .map(refresh)
580        .collect::<Option<Vec<_>>>()?;
581
582    Some(PickerOutput {
583        outputs,
584        expired_ssts,
585        time_window_size: output.time_window_size,
586        max_file_size: output.max_file_size,
587    })
588}