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