Skip to main content

meta_srv/gc/
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
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use common_event_recorder::PersistentEventContext;
20use common_meta::DatanodeId;
21use common_meta::key::runtime_switch::RuntimeSwitchManagerRef;
22use common_meta::rpc::ddl::TriggerReason;
23use common_procedure::ProcedureContext;
24use common_telemetry::tracing::Instrument as _;
25use common_telemetry::{error, info};
26use snafu::ResultExt;
27use store_api::storage::{GcReport, RegionId};
28use tokio::sync::mpsc::{Receiver, Sender};
29use tokio::sync::{Mutex, oneshot};
30
31use crate::define_ticker;
32use crate::error::{self, Error, Result};
33use crate::gc::Region2Peers;
34#[cfg(feature = "enterprise")]
35use crate::gc::ctx::PurgeOutcome;
36#[cfg(all(test, feature = "enterprise"))]
37use crate::gc::ctx::PurgeReservation;
38use crate::gc::ctx::SchedulerCtx;
39use crate::gc::dropped::DroppedRegionCollector;
40use crate::gc::options::{GcSchedulerOptions, TICKER_INTERVAL};
41use crate::gc::tracker::RegionGcTracker;
42#[cfg(feature = "enterprise")]
43use crate::metrics::METRIC_META_GC_SOFT_DROP_PURGES_TOTAL;
44use crate::metrics::{
45    METRIC_META_GC_SCHEDULER_CYCLES_TOTAL, METRIC_META_GC_SCHEDULER_DURATION_SECONDS,
46};
47
48/// Report for a GC job.
49#[derive(Debug)]
50pub enum GcJobReport {
51    PerDatanode {
52        per_datanode_reports: HashMap<DatanodeId, GcReport>,
53        failed_datanodes: HashMap<DatanodeId, Vec<Error>>,
54    },
55    Combined {
56        report: GcReport,
57    },
58}
59
60impl Default for GcJobReport {
61    fn default() -> Self {
62        Self::PerDatanode {
63            per_datanode_reports: HashMap::new(),
64            failed_datanodes: HashMap::new(),
65        }
66    }
67}
68
69impl GcJobReport {
70    pub fn combined(report: GcReport) -> Self {
71        Self::Combined { report }
72    }
73
74    pub fn merge_to_report(self) -> GcReport {
75        match self {
76            GcJobReport::Combined { report } => report,
77            GcJobReport::PerDatanode {
78                per_datanode_reports,
79                ..
80            } => {
81                let mut combined = GcReport::default();
82                for (_datanode_id, report) in per_datanode_reports {
83                    combined.merge(report);
84                }
85                combined
86            }
87        }
88    }
89}
90
91/// [`Event`] represents various types of events that can be processed by the gc ticker.
92///
93/// Variants:
94/// - `Tick`: This event is used to trigger gc periodically.
95/// - `Manually`: This event is used to trigger a manual gc run and provides a channel
96///   to send back the result for that run.
97///   Optional parameters allow specifying target regions and GC behavior.
98pub enum Event {
99    Tick,
100    Manually {
101        /// Channel sender to return the GC job report or error
102        sender: oneshot::Sender<Result<GcJobReport>>,
103        /// Optional specific region IDs to GC. If None, scheduler will select candidates automatically.
104        region_ids: Option<Vec<RegionId>>,
105        /// Optional override for full file listing. If None, uses scheduler config.
106        full_file_listing: Option<bool>,
107        /// Optional override for timeout. If None, uses scheduler config.
108        timeout: Option<Duration>,
109        procedure_context: ProcedureContext,
110    },
111}
112
113#[allow(unused)]
114pub type GcTickerRef = Arc<GcTicker>;
115
116define_ticker!(
117    /// [GcTicker] is used to trigger gc periodically.
118    GcTicker,
119    event_type = Event,
120    event_value = Event::Tick
121);
122
123/// [`GcScheduler`] is used to periodically trigger garbage collection on datanodes.
124pub struct GcScheduler {
125    pub(crate) ctx: Arc<dyn SchedulerCtx>,
126    /// Runtime switch manager to check maintenance mode.
127    pub(crate) runtime_switch_manager: RuntimeSwitchManagerRef,
128    /// The receiver of events.
129    pub(crate) receiver: Receiver<Event>,
130    /// GC configuration.
131    pub(crate) config: GcSchedulerOptions,
132    /// Tracks the last GC time for regions.
133    pub(crate) region_gc_tracker: Arc<Mutex<RegionGcTracker>>,
134    /// Last time the tracker was cleaned up.
135    pub(crate) last_tracker_cleanup: Arc<Mutex<Instant>>,
136}
137
138impl GcScheduler {
139    /// Creates a new [`GcScheduler`] with custom configuration.
140    pub(crate) fn new_with_config(
141        ctx: impl SchedulerCtx + 'static,
142        runtime_switch_manager: RuntimeSwitchManagerRef,
143        config: GcSchedulerOptions,
144    ) -> Result<(Self, GcTicker)> {
145        // Validate configuration before creating the scheduler
146        config.validate()?;
147
148        let (tx, rx) = Self::channel();
149        let gc_ticker = GcTicker::new(TICKER_INTERVAL, tx);
150        let gc_trigger = Self {
151            ctx: Arc::new(ctx),
152            runtime_switch_manager,
153            receiver: rx,
154            config,
155            region_gc_tracker: Arc::new(Mutex::new(HashMap::new())),
156            last_tracker_cleanup: Arc::new(Mutex::new(Instant::now())),
157        };
158        Ok((gc_trigger, gc_ticker))
159    }
160
161    pub(crate) fn channel() -> (Sender<Event>, Receiver<Event>) {
162        tokio::sync::mpsc::channel(8)
163    }
164
165    /// Starts the gc trigger.
166    pub fn try_start(mut self) -> Result<()> {
167        common_runtime::spawn_global(async move { self.run().await });
168        info!("GC trigger started");
169        Ok(())
170    }
171
172    pub(crate) async fn run(&mut self) {
173        while let Some(event) = self.receiver.recv().await {
174            match event {
175                Event::Tick => {
176                    info!("Received gc tick");
177                    let span =
178                        common_telemetry::tracing::info_span!("meta_gc_tick", trigger = "ticker");
179                    if let Err(e) = self.handle_tick().instrument(span).await {
180                        error!(e; "Failed to handle gc tick");
181                    }
182                }
183                Event::Manually {
184                    sender,
185                    region_ids,
186                    full_file_listing,
187                    timeout,
188                    procedure_context,
189                } => {
190                    info!("Received manually gc request");
191                    let span =
192                        common_telemetry::tracing::info_span!("meta_gc_tick", trigger = "manual");
193                    let result = self
194                        .handle_manual_gc(region_ids, full_file_listing, timeout, procedure_context)
195                        .instrument(span)
196                        .await;
197                    if let Err(e) = &result {
198                        if matches!(e, Error::ManualGcRejectedByMaintenanceMode { .. }) {
199                            info!("Rejected manual gc request: {}", e);
200                        } else {
201                            error!(e; "Failed to handle manual gc");
202                        }
203                    }
204                    let _ = sender.send(result);
205                }
206            }
207        }
208    }
209
210    pub(crate) async fn handle_tick(&self) -> Result<GcJobReport> {
211        METRIC_META_GC_SCHEDULER_CYCLES_TOTAL.inc();
212        let _timer = METRIC_META_GC_SCHEDULER_DURATION_SECONDS.start_timer();
213        info!("Start to trigger gc");
214        if self.is_maintenance_mode_enabled().await? {
215            info!("Skip gc trigger because maintenance mode is enabled");
216            return Ok(GcJobReport::default());
217        }
218        #[cfg(feature = "enterprise")]
219        if self.config.experimental_soft_drop.enable {
220            self.purge_expired_soft_dropped_tables(common_time::util::current_time_millis())
221                .await;
222        }
223        let span = common_telemetry::tracing::info_span!("meta_gc_handle_tick");
224        let report = self
225            .trigger_gc(ProcedureContext {
226                actor: None,
227                event_context: Some(PersistentEventContext::new(TriggerReason::ScheduledGc)),
228            })
229            .instrument(span)
230            .await?;
231
232        // Periodically clean up stale tracker entries
233        self.cleanup_tracker_if_needed().await?;
234
235        info!("Finished gc trigger");
236
237        Ok(report)
238    }
239
240    #[cfg(feature = "enterprise")]
241    async fn purge_expired_soft_dropped_tables(&self, now_millis: i64) {
242        // The scheduler is only constructed after GcSchedulerOptions::validate().
243        let retention_millis =
244            i64::try_from(self.config.experimental_soft_drop.retention.as_millis())
245                .unwrap_or(i64::MAX);
246        let dropped_tables = match self.ctx.list_dropped_tables().await {
247            Ok(dropped_tables) => dropped_tables,
248            Err(error) => {
249                error!(error; "Failed to list soft-dropped tables for GC");
250                return;
251            }
252        };
253        let table_count = dropped_tables.len();
254        let scan_start = self.ctx.next_purge_scan_start(table_count);
255
256        for table in dropped_tables
257            .iter()
258            .cycle()
259            .skip(scan_start)
260            .take(table_count)
261            .filter(|table| {
262                table
263                    .retention_expires_at
264                    .or_else(|| {
265                        table
266                            .dropped_at
267                            .and_then(|dropped_at| dropped_at.checked_add(retention_millis))
268                    })
269                    .is_some_and(|expires_at| expires_at <= now_millis)
270            })
271        {
272            let table_id = table.table_id;
273            let Some(reservation) = self
274                .ctx
275                .try_reserve_purge(table_id, self.config.max_concurrent_tables)
276            else {
277                continue;
278            };
279            METRIC_META_GC_SOFT_DROP_PURGES_TOTAL
280                .with_label_values(&["submitted"])
281                .inc();
282            let ctx = self.ctx.clone();
283            common_runtime::spawn_global(async move {
284                match ctx.purge_dropped_table(table_id).await {
285                    Ok(()) => reservation.record_outcome(PurgeOutcome::Succeeded),
286                    Err(error) => {
287                        reservation.record_outcome(PurgeOutcome::Failed);
288                        error!(error; "Failed to purge expired soft-dropped table {}", table_id);
289                    }
290                }
291            });
292        }
293    }
294
295    /// Handles a manual GC request with optional specific parameters.
296    ///
297    /// If `region_ids` is specified, GC will be performed only on those regions.
298    /// Otherwise, falls back to automatic candidate selection.
299    pub(crate) async fn handle_manual_gc(
300        &self,
301        region_ids: Option<Vec<RegionId>>,
302        full_file_listing: Option<bool>,
303        timeout: Option<Duration>,
304        procedure_context: ProcedureContext,
305    ) -> Result<GcJobReport> {
306        info!("Start to handle manual gc request");
307
308        if self.is_maintenance_mode_enabled().await? {
309            info!("Skip manual gc request because maintenance mode is enabled");
310            return error::ManualGcRejectedByMaintenanceModeSnafu {}.fail();
311        }
312
313        // No specific regions, use default tick behavior
314        let Some(regions) = region_ids else {
315            let report = self.trigger_gc(procedure_context).await?;
316            info!("Finished manual gc request");
317            return Ok(report);
318        };
319
320        // Empty regions list, return empty report
321        if regions.is_empty() {
322            info!("Finished manual gc request");
323            return Ok(GcJobReport::combined(GcReport::default()));
324        }
325
326        let full_listing = full_file_listing.unwrap_or(false);
327        let gc_timeout = timeout.unwrap_or(self.config.mailbox_timeout);
328
329        let region_set: HashSet<RegionId> = regions.iter().copied().collect();
330        let table_reparts = self.ctx.get_table_reparts().await?;
331        let dropped_collector =
332            DroppedRegionCollector::new(self.ctx.as_ref(), &self.config, &self.region_gc_tracker);
333        let dropped_assignment = dropped_collector
334            .collect_and_assign_with_cooldown(&table_reparts, false)
335            .await?;
336
337        let mut dropped_region_set = HashSet::new();
338        let mut dropped_routes_override = Region2Peers::new();
339        for overrides in dropped_assignment.region_routes_override.into_values() {
340            for (region_id, route) in overrides {
341                if region_set.contains(&region_id) {
342                    dropped_region_set.insert(region_id);
343                    dropped_routes_override.insert(region_id, route);
344                }
345            }
346        }
347
348        let (dropped_regions, active_regions): (Vec<_>, Vec<_>) = regions
349            .into_iter()
350            .partition(|region_id| dropped_region_set.contains(region_id));
351
352        let mut combined_report = GcReport::default();
353
354        if !active_regions.is_empty() {
355            let report = self
356                .ctx
357                .gc_regions(
358                    &active_regions,
359                    full_listing,
360                    gc_timeout,
361                    Region2Peers::new(),
362                    procedure_context.clone(),
363                )
364                .await?;
365            combined_report.merge(report);
366        }
367
368        if !dropped_regions.is_empty() {
369            let report = self
370                .ctx
371                .gc_regions(
372                    &dropped_regions,
373                    true,
374                    gc_timeout,
375                    dropped_routes_override,
376                    procedure_context,
377                )
378                .await?;
379            combined_report.merge(report);
380        }
381
382        let report = GcJobReport::combined(combined_report);
383
384        info!("Finished manual gc request");
385        Ok(report)
386    }
387
388    pub(crate) async fn is_maintenance_mode_enabled(&self) -> Result<bool> {
389        self.runtime_switch_manager
390            .maintenance_mode()
391            .await
392            .context(error::RuntimeSwitchManagerSnafu)
393    }
394}
395
396#[cfg(test)]
397pub(crate) fn new_test_runtime_switch_manager() -> RuntimeSwitchManagerRef {
398    Arc::new(common_meta::key::runtime_switch::RuntimeSwitchManager::new(
399        Arc::new(common_meta::kv_backend::memory::MemoryKvBackend::new()),
400    ))
401}
402
403#[cfg(test)]
404mod tests {
405    use std::collections::HashMap;
406    #[cfg(feature = "enterprise")]
407    use std::sync::Mutex as StdMutex;
408    use std::sync::atomic::{AtomicUsize, Ordering};
409    use std::time::Duration;
410
411    use common_meta::datanode::RegionStat;
412    #[cfg(feature = "enterprise")]
413    use common_meta::key::DroppedTableName;
414    use common_meta::key::table_repart::TableRepartValue;
415    use common_meta::key::table_route::PhysicalTableRouteValue;
416    use store_api::storage::RegionId;
417    use table::metadata::TableId;
418    #[cfg(feature = "enterprise")]
419    use table::table_name::TableName;
420
421    use super::*;
422
423    #[derive(Default)]
424    struct CountingSchedulerCtx {
425        get_table_to_region_stats_calls: AtomicUsize,
426        get_table_reparts_calls: AtomicUsize,
427        gc_regions_calls: AtomicUsize,
428        #[cfg(feature = "enterprise")]
429        list_dropped_tables_calls: AtomicUsize,
430        #[cfg(feature = "enterprise")]
431        purge_dropped_table_calls: AtomicUsize,
432    }
433
434    impl CountingSchedulerCtx {
435        fn assert_no_scheduler_work(&self) {
436            assert_eq!(
437                0,
438                self.get_table_to_region_stats_calls.load(Ordering::Relaxed),
439                "get_table_to_region_stats should not be called"
440            );
441            assert_eq!(
442                0,
443                self.get_table_reparts_calls.load(Ordering::Relaxed),
444                "get_table_reparts should not be called"
445            );
446            assert_eq!(
447                0,
448                self.gc_regions_calls.load(Ordering::Relaxed),
449                "gc_regions should not be called"
450            );
451            #[cfg(feature = "enterprise")]
452            assert_eq!(
453                0,
454                self.list_dropped_tables_calls.load(Ordering::Relaxed),
455                "list_dropped_tables should not be called"
456            );
457            #[cfg(feature = "enterprise")]
458            assert_eq!(
459                0,
460                self.purge_dropped_table_calls.load(Ordering::Relaxed),
461                "purge_dropped_table should not be called"
462            );
463        }
464    }
465
466    #[async_trait::async_trait]
467    impl SchedulerCtx for CountingSchedulerCtx {
468        async fn get_table_to_region_stats(&self) -> Result<HashMap<TableId, Vec<RegionStat>>> {
469            self.get_table_to_region_stats_calls
470                .fetch_add(1, Ordering::Relaxed);
471            panic!("get_table_to_region_stats should not be called in maintenance mode")
472        }
473
474        async fn get_table_reparts(&self) -> Result<Vec<(TableId, TableRepartValue)>> {
475            self.get_table_reparts_calls.fetch_add(1, Ordering::Relaxed);
476            panic!("get_table_reparts should not be called in maintenance mode")
477        }
478
479        async fn get_table_route(
480            &self,
481            _table_id: TableId,
482        ) -> Result<(TableId, PhysicalTableRouteValue)> {
483            unreachable!("get_table_route should not be called in this test")
484        }
485
486        async fn batch_get_table_route(
487            &self,
488            _table_ids: &[TableId],
489        ) -> Result<HashMap<TableId, PhysicalTableRouteValue>> {
490            unreachable!("batch_get_table_route should not be called in this test")
491        }
492
493        async fn gc_regions(
494            &self,
495            _region_ids: &[RegionId],
496            _full_file_listing: bool,
497            _timeout: Duration,
498            _region_routes_override: Region2Peers,
499            _procedure_context: ProcedureContext,
500        ) -> Result<GcReport> {
501            self.gc_regions_calls.fetch_add(1, Ordering::Relaxed);
502            panic!("gc_regions should not be called in maintenance mode")
503        }
504
505        #[cfg(feature = "enterprise")]
506        async fn list_dropped_tables(&self) -> Result<Vec<DroppedTableName>> {
507            self.list_dropped_tables_calls
508                .fetch_add(1, Ordering::Relaxed);
509            panic!("list_dropped_tables should not be called in maintenance mode")
510        }
511
512        #[cfg(feature = "enterprise")]
513        async fn purge_dropped_table(&self, _table_id: TableId) -> Result<()> {
514            self.purge_dropped_table_calls
515                .fetch_add(1, Ordering::Relaxed);
516            panic!("purge_dropped_table should not be called in maintenance mode")
517        }
518
519        #[cfg(feature = "enterprise")]
520        fn try_reserve_purge(
521            &self,
522            _table_id: TableId,
523            _max_in_flight: usize,
524        ) -> Option<PurgeReservation> {
525            panic!("try_reserve_purge should not be called in maintenance mode")
526        }
527    }
528
529    #[cfg(feature = "enterprise")]
530    #[derive(Default)]
531    struct SoftDropSchedulerCtx {
532        dropped_tables: StdMutex<Vec<DroppedTableName>>,
533        purge_attempts: StdMutex<Vec<TableId>>,
534        failed_table: StdMutex<Option<TableId>>,
535        never_complete_tables: StdMutex<HashSet<TableId>>,
536        in_flight_purges: Arc<StdMutex<HashSet<TableId>>>,
537        purge_scan_cursor: AtomicUsize,
538        region_gc_calls: AtomicUsize,
539    }
540
541    #[cfg(feature = "enterprise")]
542    #[async_trait::async_trait]
543    impl SchedulerCtx for SoftDropSchedulerCtx {
544        async fn get_table_to_region_stats(&self) -> Result<HashMap<TableId, Vec<RegionStat>>> {
545            self.region_gc_calls.fetch_add(1, Ordering::Relaxed);
546            Ok(HashMap::new())
547        }
548
549        async fn get_table_reparts(&self) -> Result<Vec<(TableId, TableRepartValue)>> {
550            Ok(vec![])
551        }
552
553        async fn get_table_route(
554            &self,
555            _table_id: TableId,
556        ) -> Result<(TableId, PhysicalTableRouteValue)> {
557            unreachable!()
558        }
559
560        async fn batch_get_table_route(
561            &self,
562            _table_ids: &[TableId],
563        ) -> Result<HashMap<TableId, PhysicalTableRouteValue>> {
564            Ok(HashMap::new())
565        }
566
567        async fn gc_regions(
568            &self,
569            _region_ids: &[RegionId],
570            _full_file_listing: bool,
571            _timeout: Duration,
572            _region_routes_override: Region2Peers,
573            _procedure_context: ProcedureContext,
574        ) -> Result<GcReport> {
575            Ok(GcReport::default())
576        }
577
578        async fn list_dropped_tables(&self) -> Result<Vec<DroppedTableName>> {
579            Ok(self.dropped_tables.lock().unwrap().clone())
580        }
581
582        async fn purge_dropped_table(&self, table_id: TableId) -> Result<()> {
583            self.purge_attempts.lock().unwrap().push(table_id);
584            if self
585                .never_complete_tables
586                .lock()
587                .unwrap()
588                .contains(&table_id)
589            {
590                std::future::pending().await
591            }
592            if *self.failed_table.lock().unwrap() == Some(table_id) {
593                return crate::error::UnexpectedSnafu {
594                    violated: format!("mock purge failure for table {table_id}"),
595                }
596                .fail();
597            }
598            Ok(())
599        }
600
601        fn try_reserve_purge(
602            &self,
603            table_id: TableId,
604            max_in_flight: usize,
605        ) -> Option<PurgeReservation> {
606            PurgeReservation::try_new(self.in_flight_purges.clone(), table_id, max_in_flight)
607        }
608
609        fn next_purge_scan_start(&self, table_count: usize) -> usize {
610            if table_count == 0 {
611                return 0;
612            }
613            self.purge_scan_cursor.fetch_add(1, Ordering::Relaxed) % table_count
614        }
615    }
616
617    #[cfg(feature = "enterprise")]
618    fn dropped_table(table_id: TableId, dropped_at: Option<i64>) -> DroppedTableName {
619        DroppedTableName {
620            table_id,
621            table_name: TableName::new("greptime", "public", format!("table_{table_id}")),
622            dropped_at,
623            retention_expires_at: None,
624            drop_generation: None,
625            purging: false,
626        }
627    }
628
629    #[cfg(feature = "enterprise")]
630    fn dropped_table_with_deadline(table_id: TableId, deadline: i64) -> DroppedTableName {
631        DroppedTableName {
632            retention_expires_at: Some(deadline),
633            ..dropped_table(table_id, Some(i64::MAX))
634        }
635    }
636
637    #[cfg(feature = "enterprise")]
638    fn soft_drop_scheduler(ctx: Arc<dyn SchedulerCtx>) -> GcScheduler {
639        let (tx, rx) = GcScheduler::channel();
640        drop(tx);
641        GcScheduler {
642            ctx,
643            runtime_switch_manager: new_test_runtime_switch_manager(),
644            receiver: rx,
645            config: GcSchedulerOptions {
646                enable: true,
647                experimental_soft_drop: crate::gc::options::SoftDropGcOptions {
648                    enable: true,
649                    retention: Duration::from_millis(100),
650                },
651                ..Default::default()
652            },
653            region_gc_tracker: Arc::new(Mutex::new(HashMap::new())),
654            last_tracker_cleanup: Arc::new(Mutex::new(Instant::now())),
655        }
656    }
657
658    #[cfg(feature = "enterprise")]
659    async fn wait_for_purge_attempts(ctx: &SoftDropSchedulerCtx, expected: usize) {
660        tokio::time::timeout(Duration::from_secs(1), async {
661            while ctx.purge_attempts.lock().unwrap().len() < expected {
662                tokio::task::yield_now().await;
663            }
664        })
665        .await
666        .expect("purge tasks should be scheduled");
667    }
668
669    #[cfg(feature = "enterprise")]
670    #[tokio::test]
671    async fn test_purge_expired_soft_dropped_tables_filters_by_retention_and_timestamp() {
672        let ctx = Arc::new(SoftDropSchedulerCtx::default());
673        *ctx.dropped_tables.lock().unwrap() = vec![
674            dropped_table(1, Some(899)),
675            dropped_table(2, Some(900)),
676            dropped_table(3, Some(901)),
677            dropped_table(4, None),
678            dropped_table(5, Some(i64::MAX)),
679        ];
680        let scheduler = soft_drop_scheduler(ctx.clone());
681
682        scheduler.purge_expired_soft_dropped_tables(1_000).await;
683        wait_for_purge_attempts(&ctx, 2).await;
684
685        let mut attempts = ctx.purge_attempts.lock().unwrap().clone();
686        attempts.sort_unstable();
687        assert_eq!(vec![1, 2], attempts);
688    }
689
690    #[cfg(feature = "enterprise")]
691    #[tokio::test]
692    async fn test_purge_uses_fixed_deadline_before_legacy_retention_fallback() {
693        let ctx = Arc::new(SoftDropSchedulerCtx::default());
694        *ctx.dropped_tables.lock().unwrap() = vec![
695            dropped_table_with_deadline(1, 1_000),
696            dropped_table_with_deadline(2, 1_001),
697            dropped_table(3, Some(900)),
698        ];
699        let scheduler = soft_drop_scheduler(ctx.clone());
700
701        scheduler.purge_expired_soft_dropped_tables(1_000).await;
702        wait_for_purge_attempts(&ctx, 2).await;
703
704        let mut attempts = ctx.purge_attempts.lock().unwrap().clone();
705        attempts.sort_unstable();
706        assert_eq!(vec![1, 3], attempts);
707    }
708
709    #[cfg(feature = "enterprise")]
710    #[tokio::test]
711    async fn test_purge_saturates_legacy_retention_that_exceeds_i64() {
712        let ctx = Arc::new(SoftDropSchedulerCtx::default());
713        *ctx.dropped_tables.lock().unwrap() = vec![dropped_table(1, Some(0))];
714        ctx.never_complete_tables.lock().unwrap().insert(1);
715        let mut scheduler = soft_drop_scheduler(ctx.clone());
716        scheduler.config.experimental_soft_drop.retention =
717            Duration::from_millis(i64::MAX as u64 + 1);
718
719        scheduler.purge_expired_soft_dropped_tables(0).await;
720
721        assert!(!ctx.in_flight_purges.lock().unwrap().contains(&1));
722    }
723
724    #[cfg(feature = "enterprise")]
725    #[tokio::test]
726    async fn test_purge_failure_does_not_prevent_other_purges() {
727        let ctx = Arc::new(SoftDropSchedulerCtx::default());
728        *ctx.dropped_tables.lock().unwrap() = vec![
729            dropped_table(1, Some(0)),
730            dropped_table(2, Some(0)),
731            dropped_table(3, Some(0)),
732        ];
733        *ctx.failed_table.lock().unwrap() = Some(2);
734        let scheduler = soft_drop_scheduler(ctx.clone());
735
736        scheduler.purge_expired_soft_dropped_tables(1_000).await;
737        wait_for_purge_attempts(&ctx, 3).await;
738
739        let mut attempts = ctx.purge_attempts.lock().unwrap().clone();
740        attempts.sort_unstable();
741        assert_eq!(vec![1, 2, 3], attempts);
742    }
743
744    #[cfg(feature = "enterprise")]
745    #[tokio::test]
746    async fn test_purge_scans_rotate_after_failure() {
747        let ctx = Arc::new(SoftDropSchedulerCtx::default());
748        *ctx.dropped_tables.lock().unwrap() =
749            vec![dropped_table(1, Some(0)), dropped_table(2, Some(0))];
750        *ctx.failed_table.lock().unwrap() = Some(1);
751        let mut scheduler = soft_drop_scheduler(ctx.clone());
752        scheduler.config.max_concurrent_tables = 1;
753
754        scheduler.purge_expired_soft_dropped_tables(1_000).await;
755        wait_for_purge_attempts(&ctx, 1).await;
756        tokio::time::timeout(Duration::from_secs(1), async {
757            while !ctx.in_flight_purges.lock().unwrap().is_empty() {
758                tokio::task::yield_now().await;
759            }
760        })
761        .await
762        .expect("failed purge should release its in-flight slot");
763
764        scheduler.purge_expired_soft_dropped_tables(1_000).await;
765        wait_for_purge_attempts(&ctx, 2).await;
766
767        assert_eq!(vec![1, 2], *ctx.purge_attempts.lock().unwrap());
768    }
769
770    #[cfg(feature = "enterprise")]
771    #[tokio::test]
772    async fn test_tick_purges_expired_soft_dropped_tables_and_runs_region_gc() {
773        let ctx = Arc::new(SoftDropSchedulerCtx::default());
774        *ctx.dropped_tables.lock().unwrap() = vec![dropped_table(1, Some(i64::MIN))];
775        let scheduler = soft_drop_scheduler(ctx.clone());
776
777        scheduler.handle_tick().await.unwrap();
778        wait_for_purge_attempts(&ctx, 1).await;
779
780        assert_eq!(vec![1], *ctx.purge_attempts.lock().unwrap());
781        assert_eq!(1, ctx.region_gc_calls.load(Ordering::Relaxed));
782    }
783
784    #[cfg(feature = "enterprise")]
785    #[tokio::test]
786    async fn test_tick_with_no_tombstones_makes_no_purge_submissions() {
787        let ctx = Arc::new(SoftDropSchedulerCtx::default());
788        let scheduler = soft_drop_scheduler(ctx.clone());
789
790        scheduler.handle_tick().await.unwrap();
791
792        assert!(ctx.purge_attempts.lock().unwrap().is_empty());
793        assert_eq!(1, ctx.region_gc_calls.load(Ordering::Relaxed));
794    }
795
796    #[cfg(feature = "enterprise")]
797    #[tokio::test]
798    async fn test_multiple_purge_scans_do_not_duplicate_in_flight_purges() {
799        let ctx = Arc::new(SoftDropSchedulerCtx::default());
800        *ctx.dropped_tables.lock().unwrap() = vec![
801            dropped_table(1, Some(i64::MIN)),
802            dropped_table(2, Some(i64::MIN)),
803        ];
804        ctx.never_complete_tables.lock().unwrap().insert(1);
805        *ctx.failed_table.lock().unwrap() = Some(2);
806        let mut scheduler = soft_drop_scheduler(ctx.clone());
807        scheduler.config.max_concurrent_tables = 2;
808
809        tokio::time::timeout(
810            Duration::from_millis(100),
811            scheduler.purge_expired_soft_dropped_tables(1_000),
812        )
813        .await
814        .expect("first scan should not wait for purge completion");
815        wait_for_purge_attempts(&ctx, 2).await;
816        tokio::time::timeout(Duration::from_secs(1), async {
817            while ctx.in_flight_purges.lock().unwrap().contains(&2) {
818                tokio::task::yield_now().await;
819            }
820        })
821        .await
822        .expect("terminal failure should release its in-flight slot");
823        tokio::time::timeout(
824            Duration::from_millis(100),
825            scheduler.purge_expired_soft_dropped_tables(1_000),
826        )
827        .await
828        .expect("second scan should not wait for purge completion");
829        wait_for_purge_attempts(&ctx, 3).await;
830
831        let attempts = ctx.purge_attempts.lock().unwrap().clone();
832        assert_eq!(
833            1,
834            attempts.iter().filter(|&&table_id| table_id == 1).count()
835        );
836        assert_eq!(
837            2,
838            attempts.iter().filter(|&&table_id| table_id == 2).count()
839        );
840        assert_eq!(0, ctx.region_gc_calls.load(Ordering::Relaxed));
841    }
842
843    #[cfg(feature = "enterprise")]
844    #[tokio::test]
845    async fn test_purge_scan_caps_submissions() {
846        let ctx = Arc::new(SoftDropSchedulerCtx::default());
847        *ctx.dropped_tables.lock().unwrap() = vec![
848            dropped_table(1, Some(i64::MIN)),
849            dropped_table(2, Some(i64::MIN)),
850            dropped_table(3, Some(i64::MIN)),
851        ];
852        let mut scheduler = soft_drop_scheduler(ctx.clone());
853        scheduler.config.max_concurrent_tables = 2;
854        ctx.never_complete_tables.lock().unwrap().extend([1, 2]);
855
856        scheduler.purge_expired_soft_dropped_tables(1_000).await;
857        wait_for_purge_attempts(&ctx, 2).await;
858
859        assert_eq!(2, ctx.purge_attempts.lock().unwrap().len());
860        assert_eq!(0, ctx.region_gc_calls.load(Ordering::Relaxed));
861    }
862
863    #[cfg(feature = "enterprise")]
864    #[tokio::test]
865    async fn test_manual_gc_does_not_purge_soft_dropped_tables() {
866        let ctx = Arc::new(SoftDropSchedulerCtx::default());
867        *ctx.dropped_tables.lock().unwrap() = vec![dropped_table(1, Some(i64::MIN))];
868        let scheduler = soft_drop_scheduler(ctx.clone());
869
870        scheduler
871            .handle_manual_gc(None, None, None, ProcedureContext::default())
872            .await
873            .unwrap();
874
875        assert!(ctx.purge_attempts.lock().unwrap().is_empty());
876    }
877
878    struct ErrorMockSchedulerCtx;
879
880    #[async_trait::async_trait]
881    impl SchedulerCtx for ErrorMockSchedulerCtx {
882        async fn get_table_to_region_stats(&self) -> Result<HashMap<TableId, Vec<RegionStat>>> {
883            Ok(HashMap::new())
884        }
885
886        async fn get_table_reparts(&self) -> Result<Vec<(TableId, TableRepartValue)>> {
887            Ok(vec![])
888        }
889
890        async fn get_table_route(
891            &self,
892            _table_id: TableId,
893        ) -> Result<(TableId, PhysicalTableRouteValue)> {
894            unreachable!("get_table_route should not be called in this test")
895        }
896
897        async fn batch_get_table_route(
898            &self,
899            _table_ids: &[TableId],
900        ) -> Result<HashMap<TableId, PhysicalTableRouteValue>> {
901            Ok(HashMap::new())
902        }
903
904        async fn gc_regions(
905            &self,
906            _region_ids: &[RegionId],
907            _full_file_listing: bool,
908            _timeout: Duration,
909            _region_routes_override: Region2Peers,
910            _procedure_context: ProcedureContext,
911        ) -> Result<GcReport> {
912            crate::error::UnexpectedSnafu {
913                violated: "mock gc failure".to_string(),
914            }
915            .fail()
916        }
917
918        #[cfg(feature = "enterprise")]
919        async fn list_dropped_tables(&self) -> Result<Vec<DroppedTableName>> {
920            Ok(vec![])
921        }
922
923        #[cfg(feature = "enterprise")]
924        async fn purge_dropped_table(&self, _table_id: TableId) -> Result<()> {
925            Ok(())
926        }
927
928        #[cfg(feature = "enterprise")]
929        fn try_reserve_purge(
930            &self,
931            table_id: TableId,
932            max_in_flight: usize,
933        ) -> Option<PurgeReservation> {
934            PurgeReservation::try_new(
935                Arc::new(StdMutex::new(HashSet::new())),
936                table_id,
937                max_in_flight,
938            )
939        }
940    }
941
942    #[tokio::test]
943    async fn test_handle_manual_gc_propagates_error() {
944        let (tx, rx) = GcScheduler::channel();
945        drop(tx);
946
947        let scheduler = GcScheduler {
948            ctx: Arc::new(ErrorMockSchedulerCtx),
949            runtime_switch_manager: new_test_runtime_switch_manager(),
950            receiver: rx,
951            config: GcSchedulerOptions::default(),
952            region_gc_tracker: Arc::new(Mutex::new(HashMap::new())),
953            last_tracker_cleanup: Arc::new(Mutex::new(Instant::now())),
954        };
955
956        let result = scheduler
957            .handle_manual_gc(
958                Some(vec![RegionId::new(1, 0)]),
959                Some(false),
960                Some(Duration::from_secs(1)),
961                ProcedureContext::default(),
962            )
963            .await;
964
965        assert!(result.is_err());
966    }
967
968    #[tokio::test]
969    async fn test_maintenance_mode_skips_manual_gc() {
970        let (tx, rx) = GcScheduler::channel();
971        drop(tx);
972        let runtime_switch_manager = new_test_runtime_switch_manager();
973        runtime_switch_manager.set_maintenance_mode().await.unwrap();
974
975        let ctx = Arc::new(CountingSchedulerCtx::default());
976        let scheduler = GcScheduler {
977            ctx: ctx.clone(),
978            runtime_switch_manager,
979            receiver: rx,
980            config: GcSchedulerOptions::default(),
981            region_gc_tracker: Arc::new(Mutex::new(HashMap::new())),
982            last_tracker_cleanup: Arc::new(Mutex::new(Instant::now())),
983        };
984
985        let result = scheduler
986            .handle_manual_gc(
987                Some(vec![RegionId::new(1, 0)]),
988                Some(false),
989                Some(Duration::from_secs(1)),
990                ProcedureContext::default(),
991            )
992            .await;
993
994        let err = result.unwrap_err();
995        assert!(matches!(
996            err,
997            error::Error::ManualGcRejectedByMaintenanceMode { .. }
998        ));
999        assert!(err.to_string().contains("maintenance mode is enabled"));
1000        ctx.assert_no_scheduler_work();
1001    }
1002
1003    #[tokio::test]
1004    async fn test_maintenance_mode_skips_tick_gc() {
1005        let (tx, rx) = GcScheduler::channel();
1006        drop(tx);
1007        let runtime_switch_manager = new_test_runtime_switch_manager();
1008        runtime_switch_manager.set_maintenance_mode().await.unwrap();
1009
1010        let ctx = Arc::new(CountingSchedulerCtx::default());
1011        let scheduler = GcScheduler {
1012            ctx: ctx.clone(),
1013            runtime_switch_manager,
1014            receiver: rx,
1015            config: GcSchedulerOptions::default(),
1016            region_gc_tracker: Arc::new(Mutex::new(HashMap::new())),
1017            last_tracker_cleanup: Arc::new(Mutex::new(Instant::now())),
1018        };
1019
1020        let result = scheduler.handle_tick().await;
1021
1022        assert!(result.is_ok());
1023        ctx.assert_no_scheduler_work();
1024    }
1025}