Skip to main content

mito2/compaction/
scheduler.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
15mod coordinator;
16pub(crate) mod history;
17mod local;
18mod planning;
19mod state;
20
21use std::collections::HashMap;
22use std::sync::{Arc, LazyLock};
23
24use api::v1::region::compact_request;
25use common_base::Plugins;
26use common_memory_manager::OnExhaustedPolicy;
27use common_meta::key::SchemaMetadataManagerRef;
28#[cfg(test)]
29use common_recordbatch::filter::MetadataFilter;
30use common_telemetry::{debug, error, info, warn};
31use common_time::range::TimestampRange;
32pub(crate) use coordinator::{CompactionCoordinator, ReadyUnit};
33#[cfg(test)]
34use datafusion_common::ScalarValue;
35pub(crate) use planning::CompactionPickFinished;
36pub use planning::CompactionRequest;
37#[cfg(test)]
38pub(crate) use planning::pick_automatic_for_test;
39pub(crate) use state::{CompactionExecution, CompactionState, CompactionStateRef};
40use state::{CompactionStatus, CompactionTrigger, DdlExecution, PendingCompaction};
41#[cfg(test)]
42use store_api::compaction_task::{self, CompactionTaskEntry};
43use store_api::storage::RegionId;
44use tokio::sync::mpsc::Sender;
45
46use crate::access_layer::AccessLayerRef;
47use crate::cache::CacheManagerRef;
48use crate::compaction::memory_manager::CompactionMemoryManager;
49use crate::config::MitoConfig;
50use crate::error::{
51    CompactionCancelledSnafu, Error, ManualCompactionAlreadyRunningSnafu, RegionClosedSnafu,
52    RegionDroppedSnafu, RegionTruncatedSnafu, Result, UnsupportedOperationSnafu,
53};
54use crate::region::version::VersionControlRef;
55use crate::region::{ManifestContextRef, RegionLeaderState, RegionRoleState};
56use crate::request::{DdlRequest, OptionOutputTx, SenderDdlRequest, WorkerRequestWithTime};
57#[cfg(test)]
58use crate::schedule::RequestCancelResult;
59use crate::worker::WorkerListener;
60
61/// Internal testing switch to prevent SST compaction from changing file layouts
62/// during query regression tests. This is not a public configuration option.
63const DISABLE_COMPACTION_ENV: &str = "GREPTIME_DISABLE_COMPACTION";
64
65/// Internal query-regression switch, read once when the first scheduler starts.
66/// Applies to SST compaction only; ingestion and flushing remain enabled.
67static DISABLE_COMPACTION: LazyLock<bool> = LazyLock::new(|| {
68    let value = std::env::var(DISABLE_COMPACTION_ENV).ok();
69    let disabled = parse_disable_compaction(value.as_deref());
70    if disabled {
71        warn!(
72            "SST compaction is disabled by {DISABLE_COMPACTION_ENV}; restart without it to resume"
73        );
74    }
75    disabled
76});
77
78fn parse_disable_compaction(value: Option<&str>) -> bool {
79    match value {
80        None | Some("false") => false,
81        Some("true") => true,
82        Some(_) => {
83            warn!(
84                "Invalid {DISABLE_COMPACTION_ENV}: expected true or false; compaction remains enabled"
85            );
86            false
87        }
88    }
89}
90
91/// Compaction scheduler tracks and manages compaction tasks.
92pub(crate) struct CompactionScheduler {
93    /// Disables SST compaction for internal query regression tests.
94    /// Initialized from [`DISABLE_COMPACTION`] and fixed for the scheduler's lifetime
95    /// in production; unit tests can override it without changing the process environment.
96    disable_compaction: bool,
97    coordinator: Arc<CompactionCoordinator>,
98    /// All loaded regions, including idle regions and regions executing DDLs.
99    region_status: HashMap<RegionId, CompactionStatus>,
100    /// Request sender of the worker that this scheduler belongs to.
101    request_sender: Sender<WorkerRequestWithTime>,
102    cache_manager: CacheManagerRef,
103    engine_config: Arc<MitoConfig>,
104    memory_manager: Arc<CompactionMemoryManager>,
105    memory_policy: OnExhaustedPolicy,
106    listener: WorkerListener,
107    /// Plugins for the compaction scheduler.
108    plugins: Plugins,
109    /// Scheduler-wide generation counter for compaction plans and executions.
110    /// It outlives region statuses so close/reopen cannot reuse an old identity.
111    next_plan_id: u64,
112}
113
114/// Describes the immediate action produced by a compaction terminal transition.
115#[derive(Debug)]
116pub(crate) enum CompactionTransition {
117    /// No follow-up work was dispatched and no DDL became ready.
118    NoAction,
119    /// An automatic follow-up planning cycle was dispatched.
120    AutomaticFollowupScheduled,
121    /// DDL requests became ready after the compaction lifecycle terminated.
122    DdlReady(Vec<SenderDdlRequest>),
123}
124
125impl CompactionTransition {
126    fn from_pending_ddls(pending_ddls: Vec<SenderDdlRequest>) -> Self {
127        if pending_ddls.is_empty() {
128            Self::NoAction
129        } else {
130            Self::DdlReady(pending_ddls)
131        }
132    }
133
134    #[cfg(test)]
135    fn is_empty(&self) -> bool {
136        !matches!(self, Self::DdlReady(pending_ddls) if !pending_ddls.is_empty())
137    }
138
139    #[cfg(test)]
140    fn len(&self) -> usize {
141        match self {
142            Self::DdlReady(pending_ddls) => pending_ddls.len(),
143            Self::NoAction | Self::AutomaticFollowupScheduled => 0,
144        }
145    }
146}
147
148// API used by callers outside the compaction scheduler module.
149impl CompactionScheduler {
150    /// Creates an empty scheduler bound to one region worker.
151    ///
152    /// # Effects
153    ///
154    /// Stores the worker request sender and shared scheduling resources. No
155    /// compaction is dispatched until a scheduling method is called.
156    ///
157    /// # Constraints
158    ///
159    /// All lifecycle methods on the returned scheduler must be driven by the
160    /// same worker so region state transitions remain serialized.
161    #[allow(clippy::too_many_arguments)]
162    pub(crate) fn new(
163        coordinator: Arc<CompactionCoordinator>,
164        request_sender: Sender<WorkerRequestWithTime>,
165        cache_manager: CacheManagerRef,
166        engine_config: Arc<MitoConfig>,
167        listener: WorkerListener,
168        plugins: Plugins,
169        memory_manager: Arc<CompactionMemoryManager>,
170        memory_policy: OnExhaustedPolicy,
171    ) -> Self {
172        Self {
173            disable_compaction: *DISABLE_COMPACTION,
174            coordinator,
175            region_status: HashMap::new(),
176            request_sender,
177            cache_manager,
178            engine_config,
179            memory_manager,
180            memory_policy,
181            listener,
182            plugins,
183            next_plan_id: 0,
184        }
185    }
186
187    /// Registers a loaded region; replacing its VersionControl invalidates the old incarnation.
188    pub(crate) fn register_region(
189        &mut self,
190        version_control: &VersionControlRef,
191        access_layer: &AccessLayerRef,
192        shared_state: &CompactionStateRef,
193    ) {
194        let region_id = version_control.region_id();
195        if let Some(status) = self.region_status.get_mut(&region_id) {
196            if Arc::ptr_eq(&status.version_control, version_control) {
197                return;
198            }
199            status.on_failure(Arc::new(RegionClosedSnafu { region_id }.build()));
200        }
201        let mut status =
202            CompactionStatus::new(region_id, version_control.clone(), access_layer.clone());
203        status.shared_state = shared_state.clone();
204        self.region_status.insert(region_id, status);
205    }
206
207    /// Reports active work rather than the existence of a resident region record.
208    #[cfg(test)]
209    fn is_compacting(&self, region_id: RegionId) -> bool {
210        self.region_status
211            .get(&region_id)
212            .is_some_and(CompactionStatus::is_compacting)
213    }
214
215    /// Accepts an automatic compaction trigger for a region.
216    ///
217    /// # Effects
218    ///
219    /// Starts planning when the registered region is idle.
220    /// Otherwise, an active status coalesces the trigger into one unrestricted
221    /// follow-up cycle. Returns `true` only when this call dispatches planning.
222    ///
223    /// # Constraints
224    ///
225    /// The owning region worker must call this method serially. Automatic
226    /// triggers have no waiter and may be ignored while a DDL fence is active.
227    /// The region identity comes from `version_control`; `access_layer` and
228    /// `manifest_ctx` must belong to the same region.
229    pub(crate) fn schedule_automatic_compaction(
230        &mut self,
231        compact_options: compact_request::Options,
232        version_control: &VersionControlRef,
233        access_layer: &AccessLayerRef,
234        manifest_ctx: &ManifestContextRef,
235        schema_metadata_manager: SchemaMetadataManagerRef,
236    ) -> Result<bool> {
237        self.schedule_compaction(
238            CompactionTrigger::Automatic,
239            compact_options,
240            version_control,
241            access_layer,
242            OptionOutputTx::none(),
243            manifest_ctx,
244            schema_metadata_manager,
245            self.engine_config.max_background_compactions,
246            None,
247        )
248    }
249
250    /// Accepts a manual compaction request for a region.
251    ///
252    /// # Effects
253    ///
254    /// Starts planning when the registered region is idle. During
255    /// an automatic compaction, queues the request and replaces any older
256    /// pending manual request; during a manual compaction, rejects the new
257    /// request through `waiter`. Returns `true` only when this call dispatches
258    /// planning.
259    ///
260    /// # Constraints
261    ///
262    /// The owning region worker must call this method serially. The supplied
263    /// waiter is completed exactly once by cycle completion, replacement,
264    /// cancellation, failure, or region teardown. The region identity comes
265    /// from `version_control`; `access_layer` and `manifest_ctx` must belong to
266    /// the same region.
267    #[allow(clippy::too_many_arguments)]
268    pub(crate) fn schedule_manual_compaction(
269        &mut self,
270        compact_options: compact_request::Options,
271        version_control: &VersionControlRef,
272        access_layer: &AccessLayerRef,
273        waiter: OptionOutputTx,
274        manifest_ctx: &ManifestContextRef,
275        schema_metadata_manager: SchemaMetadataManagerRef,
276        max_parallelism: usize,
277        time_range: Option<TimestampRange>,
278    ) -> Result<bool> {
279        self.schedule_compaction(
280            CompactionTrigger::Manual,
281            compact_options,
282            version_control,
283            access_layer,
284            waiter,
285            manifest_ctx,
286            schema_metadata_manager,
287            max_parallelism,
288            time_range,
289        )
290    }
291
292    /// Applies a background planning result to the current region state.
293    ///
294    /// # Effects
295    ///
296    /// Rejects stale plans, submits a prepared local or remote execution, or
297    /// completes the cycle when planning produced no executable plan. The
298    /// returned transition reports a dispatched automatic follow-up or DDL
299    /// requests released by the resulting terminal transition.
300    ///
301    /// # Constraints
302    ///
303    /// The owning worker must call this method for planning notifications and
304    /// must execute every returned DDL request. The notification may be stale;
305    /// stale notifications intentionally have no effect.
306    pub(crate) async fn handle_compaction_pick_finished(
307        &mut self,
308        finished: CompactionPickFinished,
309        manifest_ctx: &ManifestContextRef,
310        schema_metadata_manager: SchemaMetadataManagerRef,
311    ) -> CompactionTransition {
312        self.handle_compaction_pick_finished_inner(finished, manifest_ctx, schema_metadata_manager)
313            .await
314    }
315
316    /// Returns whether a terminal notification belongs to the installed execution.
317    ///
318    /// # Effects
319    ///
320    /// Performs a read-only identity check against the region's current plan.
321    ///
322    /// # Constraints
323    ///
324    /// Callers must check this before applying a compaction edit. Matching only
325    /// the region id is insufficient because close/reopen can replace a status.
326    pub(crate) fn is_current_execution(
327        &self,
328        region_id: RegionId,
329        execution: &CompactionExecution,
330    ) -> bool {
331        self.region_status
332            .get(&region_id)
333            .is_some_and(|status| status.matches_execution(execution))
334    }
335
336    /// Completes the installed execution after its edit has been applied.
337    ///
338    /// # Effects
339    ///
340    /// Notifies waiters, schedules a pending manual or automatic follow-up, or
341    /// returns the region to idle. A follow-up is scheduled whenever the cycle
342    /// latched an automatic trigger, or when `made_progress` is true (the
343    /// execution produced output or reduced the file count): successful compaction
344    /// keeps draining the region until the picker returns no plan. The returned
345    /// transition reports a dispatched automatic follow-up or DDLs that are now
346    /// safe to execute.
347    ///
348    /// # Constraints
349    ///
350    /// The owning worker must call this only after accepting and applying the
351    /// execution's result, and must execute every returned DDL request. Stale
352    /// executions are ignored.
353    pub(crate) async fn on_execution_finished(
354        &mut self,
355        region_id: RegionId,
356        execution: &CompactionExecution,
357        manifest_ctx: &ManifestContextRef,
358        schema_metadata_manager: SchemaMetadataManagerRef,
359        made_progress: bool,
360    ) -> CompactionTransition {
361        if !self.is_current_execution(region_id, execution) {
362            return CompactionTransition::NoAction;
363        }
364        self.on_compaction_finished(
365            region_id,
366            manifest_ctx,
367            schema_metadata_manager,
368            made_progress,
369        )
370        .await
371    }
372
373    /// Records failure of the installed execution.
374    ///
375    /// # Effects
376    ///
377    /// Resets the matching region to idle and fails all compaction waiters and
378    /// dependent DDL requests with the supplied error.
379    ///
380    /// # Constraints
381    ///
382    /// A stale execution failure is ignored so it cannot tear down replacement
383    /// state.
384    pub(crate) fn on_execution_failed(
385        &mut self,
386        region_id: RegionId,
387        execution: &CompactionExecution,
388        err: Arc<Error>,
389    ) {
390        if !self.is_current_execution(region_id, execution) {
391            return;
392        }
393        self.on_compaction_failed(region_id, err);
394    }
395
396    /// Removes compaction state because the region was dropped.
397    ///
398    /// # Effects
399    ///
400    /// Fails all compaction and DDL waiters owned by the status.
401    ///
402    /// # Constraints
403    ///
404    /// The owning worker must invoke this as part of serialized region teardown.
405    pub(crate) fn on_region_dropped(&mut self, region_id: RegionId) {
406        self.remove_region(
407            region_id,
408            Arc::new(RegionDroppedSnafu { region_id }.build()),
409        );
410    }
411
412    /// Removes compaction state because the region was closed.
413    ///
414    /// # Effects
415    ///
416    /// Fails all compaction and DDL waiters owned by the status.
417    ///
418    /// # Constraints
419    ///
420    /// The owning worker must invoke this as part of serialized region teardown.
421    pub(crate) fn on_region_closed(&mut self, region_id: RegionId) {
422        self.remove_region(region_id, Arc::new(RegionClosedSnafu { region_id }.build()));
423    }
424
425    /// Resets the cycle after truncate, retaining registration and outstanding DDL replies.
426    ///
427    /// # Effects
428    ///
429    /// Fails all compaction and DDL waiters owned by the status.
430    ///
431    /// # Constraints
432    ///
433    /// The owning worker must invoke this after truncate completes.
434    pub(crate) fn on_region_truncated(&mut self, region_id: RegionId) {
435        self.fail_compaction(
436            region_id,
437            Arc::new(RegionTruncatedSnafu { region_id }.build()),
438        );
439    }
440
441    /// Cancels a running compaction and queues its dependent DDL atomically.
442    ///
443    /// # Effects
444    ///
445    /// Requests cancellation when the active phase can still stop, then queues
446    /// the DDL. The queued DDL forms a scheduling fence for subsequent
447    /// compaction triggers. If no compaction is running, returns the sender and
448    /// typed request unchanged.
449    ///
450    /// # Constraints
451    ///
452    /// Production callers use this only for [`DdlRequest::Truncate`] and
453    /// [`DdlRequest::EnterStaging`]. `Ok(())` means the caller must wait for a
454    /// terminal callback to receive and execute the queued DDL; `Err` means the
455    /// caller must execute the returned request directly.
456    pub(crate) fn try_cancel_and_add_ddl<T>(
457        &mut self,
458        region_id: RegionId,
459        sender: OptionOutputTx,
460        request: T,
461        into_ddl_request: impl FnOnce(T) -> DdlRequest,
462    ) -> std::result::Result<(), (OptionOutputTx, T)> {
463        let Some(status) = self.region_status.get_mut(&region_id) else {
464            return Err((sender, request));
465        };
466        if !status.is_compacting() {
467            return Err((sender, request));
468        }
469        status.request_cancel();
470
471        let request = SenderDdlRequest {
472            region_id,
473            sender,
474            request: into_ddl_request(request),
475        };
476        debug!(
477            "Added pending DDL request for region: {}, ddl: {:?}",
478            request.region_id, request.request
479        );
480        status.pending_ddl_requests.push(request);
481        Ok(())
482    }
483}
484
485// Internal state-machine helpers.
486impl CompactionScheduler {
487    /// Returns the current plan id and advances the counter.
488    ///
489    /// Takes the counter instead of `&mut self` so callers can bump it while
490    /// holding a mutable borrow of a region status.
491    fn next_plan_id(counter: &mut u64) -> u64 {
492        let plan_id = *counter;
493        *counter = counter.wrapping_add(1);
494        plan_id
495    }
496
497    #[allow(clippy::too_many_arguments)]
498    fn schedule_compaction(
499        &mut self,
500        trigger: CompactionTrigger,
501        compact_options: compact_request::Options,
502        version_control: &VersionControlRef,
503        access_layer: &AccessLayerRef,
504        waiter: OptionOutputTx,
505        manifest_ctx: &ManifestContextRef,
506        schema_metadata_manager: SchemaMetadataManagerRef,
507        max_parallelism: usize,
508        time_range: Option<TimestampRange>,
509    ) -> Result<bool> {
510        if self.disable_compaction {
511            if matches!(trigger, CompactionTrigger::Manual) {
512                waiter.send(
513                    UnsupportedOperationSnafu {
514                        err_msg: format!("SST compaction is disabled by {DISABLE_COMPACTION_ENV}"),
515                    }
516                    .fail(),
517                );
518            }
519            return Ok(false);
520        }
521        let region_id = version_control.region_id();
522        let status = self.region_status.entry(region_id).or_insert_with(|| {
523            CompactionStatus::new(region_id, version_control.clone(), access_layer.clone())
524        });
525        if status.has_ddl() {
526            info!(
527                "Region {} has pending DDL requests, ignoring compaction: queued={}, executing={}",
528                region_id,
529                status.pending_ddl_requests.len(),
530                status.executing_ddl.is_some(),
531            );
532            waiter.send(CompactionCancelledSnafu.fail());
533            return Ok(false);
534        }
535        let current_state = manifest_ctx.current_state();
536        if current_state == RegionRoleState::Leader(RegionLeaderState::Staging) {
537            info!(
538                "Skipping compaction for region {} in staging mode, options: {:?}",
539                region_id, compact_options
540            );
541            waiter.send(Ok(0));
542            return Ok(false);
543        }
544
545        if status.is_compacting() {
546            match trigger {
547                CompactionTrigger::Automatic => status.mark_automatic_trigger(),
548                CompactionTrigger::Manual if status.is_manual_compaction() => {
549                    waiter.send(ManualCompactionAlreadyRunningSnafu { region_id }.fail());
550                    info!(
551                        "Region {} already has a manually triggered compaction running",
552                        region_id
553                    );
554                }
555                CompactionTrigger::Manual => {
556                    status.set_pending_request(PendingCompaction {
557                        options: compact_options,
558                        waiter,
559                        max_parallelism,
560                        time_range,
561                    });
562                    info!(
563                        "Region {} is running an automatic compaction; manual compaction will be re-scheduled",
564                        region_id
565                    );
566                }
567            }
568            return Ok(false);
569        }
570
571        // Publish the picking phase before dispatching background planning.
572        let plan_id = Self::next_plan_id(&mut self.next_plan_id);
573        status.start_picking_with_trigger(plan_id, trigger);
574        let pick_history = (trigger == CompactionTrigger::Automatic
575            && matches!(compact_options, compact_request::Options::Regular(_)))
576        .then(|| status.pick_history.clone());
577        let request = status.new_compaction_request(
578            self.request_sender.clone(),
579            self.engine_config.clone(),
580            self.cache_manager.clone(),
581            manifest_ctx,
582            self.listener.clone(),
583            schema_metadata_manager,
584            max_parallelism,
585            pick_history,
586        );
587        status.merge_waiter(waiter);
588        self.dispatch_compaction_planning(plan_id, request, compact_options, time_range, trigger);
589        self.listener.on_compaction_scheduled(region_id);
590        Ok(true)
591    }
592
593    // Handle pending manual compaction request for the region.
594    //
595    // Returns true if should early return, false otherwise.
596    fn handle_pending_compaction_request(
597        &mut self,
598        region_id: RegionId,
599        manifest_ctx: &ManifestContextRef,
600        schema_metadata_manager: SchemaMetadataManagerRef,
601    ) -> bool {
602        let Some(status) = self.region_status.get_mut(&region_id) else {
603            return true;
604        };
605
606        // If there is a pending manual compaction request, schedule it.
607        // and defer returning the pending DDL requests to the caller.
608        let Some(pending_request) = std::mem::take(&mut status.pending_request) else {
609            return false;
610        };
611
612        let PendingCompaction {
613            options,
614            waiter,
615            max_parallelism,
616            time_range,
617        } = pending_request;
618
619        let request = status.new_compaction_request(
620            self.request_sender.clone(),
621            self.engine_config.clone(),
622            self.cache_manager.clone(),
623            manifest_ctx,
624            self.listener.clone(),
625            schema_metadata_manager,
626            max_parallelism,
627            None,
628        );
629        status.merge_waiter(waiter);
630        // Bump the counter through a disjoint field borrow so the `status`
631        // borrow stays alive; nothing could have removed the status since it
632        // was fetched above.
633        let plan_id = Self::next_plan_id(&mut self.next_plan_id);
634        status.start_picking_with_trigger(plan_id, CompactionTrigger::Manual);
635        status.active.task_state = status.pending_task_state.take();
636        self.dispatch_compaction_planning(
637            plan_id,
638            request,
639            options,
640            time_range,
641            CompactionTrigger::Manual,
642        );
643        debug!(
644            "Successfully scheduled manual compaction planning for region id: {}",
645            region_id
646        );
647        true
648    }
649
650    /// Notifies the scheduler that the compaction job is finished successfully.
651    async fn on_compaction_finished(
652        &mut self,
653        region_id: RegionId,
654        manifest_ctx: &ManifestContextRef,
655        schema_metadata_manager: SchemaMetadataManagerRef,
656        made_progress: bool,
657    ) -> CompactionTransition {
658        if let Some(ddls) = self.take_ready_ddls(region_id) {
659            return CompactionTransition::DdlReady(ddls);
660        }
661
662        if self.handle_pending_compaction_request(
663            region_id,
664            manifest_ctx,
665            schema_metadata_manager.clone(),
666        ) {
667            return CompactionTransition::NoAction;
668        }
669
670        let Some(status) = self.region_status.get_mut(&region_id) else {
671            return CompactionTransition::NoAction;
672        };
673        for waiter in status.take_waiters() {
674            waiter.send(Ok(0));
675        }
676
677        // Keep draining when the cycle latched an automatic trigger, or when the
678        // execution produced output or reduced the file count. An empty edit stops
679        // here; the next flush trigger resumes.
680        let should_continue = status.active.reset_automatic_followup() || made_progress;
681        if should_continue
682            && self.schedule_automatic_followup(region_id, manifest_ctx, schema_metadata_manager)
683        {
684            return CompactionTransition::AutomaticFollowupScheduled;
685        }
686        if let Some(status) = self.region_status.get_mut(&region_id) {
687            status.become_idle();
688        }
689        CompactionTransition::NoAction
690    }
691
692    fn schedule_automatic_followup(
693        &mut self,
694        region_id: RegionId,
695        manifest_ctx: &ManifestContextRef,
696        schema_metadata_manager: SchemaMetadataManagerRef,
697    ) -> bool {
698        if self.disable_compaction {
699            return false;
700        }
701        let Some(status) = self.region_status.get_mut(&region_id) else {
702            return false;
703        };
704        // An external automatic trigger requires one unrestricted follow-up pick.
705        let request = status.new_compaction_request(
706            self.request_sender.clone(),
707            self.engine_config.clone(),
708            self.cache_manager.clone(),
709            manifest_ctx,
710            self.listener.clone(),
711            schema_metadata_manager,
712            self.engine_config.max_background_compactions,
713            Some(status.pick_history.clone()),
714        );
715        // Bump the counter through a disjoint field borrow so the `status`
716        // borrow stays alive; nothing could have removed the status since it
717        // was fetched above.
718        let plan_id = Self::next_plan_id(&mut self.next_plan_id);
719        status.start_regular_picking(plan_id);
720        self.dispatch_compaction_planning(
721            plan_id,
722            request,
723            compact_request::Options::Regular(Default::default()),
724            None,
725            CompactionTrigger::Automatic,
726        );
727        debug!(
728            "Successfully scheduled next compaction planning for region id: {}",
729            region_id
730        );
731        true
732    }
733
734    /// Notifies the scheduler that the compaction job is cancelled cooperatively.
735    #[cfg(test)]
736    async fn on_compaction_cancelled(&mut self, region_id: RegionId) -> Vec<SenderDdlRequest> {
737        self.finish_compaction_on_cancel(region_id)
738    }
739
740    /// Notifies the scheduler that the compaction job is failed.
741    fn on_compaction_failed(&mut self, region_id: RegionId, err: Arc<Error>) {
742        error!(err; "Region {} failed to compact, cancel all pending tasks", region_id);
743        self.fail_compaction(region_id, err);
744    }
745
746    #[cfg(test)]
747    fn add_ddl_request_to_pending(&mut self, request: SenderDdlRequest) {
748        self.region_status
749            .get_mut(&request.region_id)
750            .unwrap()
751            .pending_ddl_requests
752            .push(request);
753    }
754
755    #[cfg(test)]
756    fn has_pending_ddls(&self, region_id: RegionId) -> bool {
757        let has_pending = self
758            .region_status
759            .get(&region_id)
760            .map(|status| !status.pending_ddl_requests.is_empty())
761            .unwrap_or(false);
762        debug!(
763            "Checked pending DDL requests for region: {}, has_pending: {}",
764            region_id, has_pending
765        );
766        has_pending
767    }
768
769    #[cfg(test)]
770    fn request_cancel(&mut self, region_id: RegionId) -> RequestCancelResult {
771        self.region_status
772            .get_mut(&region_id)
773            .unwrap()
774            .request_cancel()
775    }
776
777    /// Unregisters a closed or dropped region and fails its queued requests.
778    fn remove_region(&mut self, region_id: RegionId, err: Arc<Error>) {
779        if let Some(mut status) = self.region_status.remove(&region_id) {
780            status.on_failure(err);
781        }
782        self.coordinator.forget_region(region_id);
783    }
784
785    /// Fails a cycle without discarding the loaded region's registration.
786    fn fail_compaction(&mut self, region_id: RegionId, err: Arc<Error>) {
787        if let Some(status) = self.region_status.get_mut(&region_id) {
788            status.on_failure(err);
789        }
790    }
791
792    /// Returns the cycle to idle and records any DDL handoff in the same resident entry.
793    fn finish_compaction_on_cancel(&mut self, region_id: RegionId) -> Vec<SenderDdlRequest> {
794        let Some(status) = self.region_status.get_mut(&region_id) else {
795            return Vec::new();
796        };
797
798        let ddls = status.on_cancel();
799        self.fence_ddl_replies(region_id, ddls)
800    }
801
802    /// Hands off pending DDLs after compaction ends and cancels remaining compaction waiters.
803    fn take_ready_ddls(&mut self, region_id: RegionId) -> Option<Vec<SenderDdlRequest>> {
804        if self
805            .region_status
806            .get(&region_id)?
807            .pending_ddl_requests
808            .is_empty()
809        {
810            return None;
811        }
812        Some(self.finish_compaction_on_cancel(region_id))
813    }
814
815    /// Hands off a drained batch of DDLs and fences new compaction until every reply lands.
816    ///
817    /// Installs the fence, intercepts each DDL's reply, and returns the same batch so the caller
818    /// can execute it. The real result of each DDL is still forwarded to its original sender.
819    ///
820    /// # Constraints
821    /// Must only be called from a compaction terminal transition, and only by the worker that owns
822    /// `region_id`.
823    fn fence_ddl_replies(
824        &mut self,
825        region_id: RegionId,
826        mut ddls: Vec<SenderDdlRequest>,
827    ) -> Vec<SenderDdlRequest> {
828        if ddls.is_empty() {
829            return ddls;
830        }
831        let generation = Self::next_plan_id(&mut self.next_plan_id);
832        if let Some(status) = self.region_status.get_mut(&region_id) {
833            // Set region status to "executing DDLs" so future compaction won't be scheduled.
834            status.executing_ddl = Some(DdlExecution::new(generation, ddls.len()));
835        }
836
837        for (request_index, ddl) in ddls.iter_mut().enumerate() {
838            // Swap the DDL's real reply sender for a temporary one so we can observe it. The
839            // spawned task forwards the observed result to the original caller and, in between,
840            // notifies the scheduler that this slot of the batch has completed.
841            let (tx, rx) = tokio::sync::oneshot::channel();
842            let reply = std::mem::replace(&mut ddl.sender, tx.into());
843            let sender = self.request_sender.clone();
844            common_runtime::spawn_global(async move {
845                use snafu::ResultExt;
846                let result = rx
847                    .await
848                    .context(crate::error::RecvSnafu)
849                    .and_then(|result| result);
850                // Route the completion through the worker channel so `on_ddl_complete` sees it
851                // serialized with other region state transitions.
852                //
853                // At most one batch can be outstanding per region: [`CompactionStatus::has_ddl`] blocks
854                // compaction admission while `executing_ddl` is set, so `fence_ddl_replies` cannot run again
855                // for the same region before the current batch drains. A single `(generation, request_index)`
856                // pair therefore identifies every reply. `generation` comes from the scheduler-wide counter,
857                // which outlives region statuses, so a late reply from a previous incarnation cannot clear the
858                // current fence. Inside the batch `request_index` makes completion idempotent: only the last
859                // distinct reply releases the fence.
860                let _ = sender
861                    .send(WorkerRequestWithTime::new(
862                        crate::request::WorkerRequest::Background {
863                            region_id,
864                            notify: crate::request::BackgroundNotify::CompactionDdlComplete {
865                                generation,
866                                request_index,
867                            },
868                        },
869                    ))
870                    .await;
871                reply.send(result);
872            });
873        }
874        ddls
875    }
876
877    /// Releases the DDL fence after its last reply, ignoring replies from an older generation.
878    pub(crate) fn on_ddl_complete(
879        &mut self,
880        region_id: RegionId,
881        generation: u64,
882        request_index: usize,
883    ) {
884        let Some(status) = self.region_status.get_mut(&region_id) else {
885            return;
886        };
887        // `complete` returns false for a stale generation or a duplicate `request_index`, so a late or
888        // repeated notify cannot end the batch early. It only clears the fence on the last distinct
889        // reply, which is what lets `has_ddl` unblock compaction admission again.
890        if let Some(ddl) = &mut status.executing_ddl
891            && ddl.complete(generation, request_index)
892        {
893            status.executing_ddl = None;
894        }
895    }
896}
897
898impl Drop for CompactionScheduler {
899    fn drop(&mut self) {
900        for (region_id, mut status) in self.region_status.drain() {
901            // We are shutting down so notify all pending tasks.
902            status.on_failure(Arc::new(RegionClosedSnafu { region_id }.build()));
903            self.coordinator.forget_region(region_id);
904        }
905    }
906}
907
908impl CompactionScheduler {
909    /// Reads the same published task state as engine queries.
910    #[cfg(test)]
911    pub(crate) fn inspect_tasks(
912        &self,
913        filters: &MetadataFilter,
914        node_id: Option<u64>,
915    ) -> Vec<CompactionTaskEntry> {
916        let mut entries = Vec::new();
917        for (region_id, status) in &self.region_status {
918            if !filters.matches(|name| match name {
919                compaction_task::REGION_ID => Some(ScalarValue::UInt64(Some(region_id.as_u64()))),
920                compaction_task::TABLE_ID => Some(ScalarValue::UInt32(Some(region_id.table_id()))),
921                compaction_task::NODE_ID => Some(ScalarValue::UInt64(node_id)),
922                _ => None,
923            }) {
924                continue;
925            }
926            let (active, pending) = CompactionState::current(&status.shared_state);
927            for task in active.into_iter().chain(pending) {
928                let entry = task.snapshot(node_id);
929                if entry.matches(filters) {
930                    entries.push(entry);
931                }
932            }
933        }
934        entries
935    }
936}
937
938#[cfg(test)]
939#[path = "scheduler_test.rs"]
940mod tests;