1use 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#[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
91pub enum Event {
99 Tick,
100 Manually {
101 sender: oneshot::Sender<Result<GcJobReport>>,
103 region_ids: Option<Vec<RegionId>>,
105 full_file_listing: Option<bool>,
107 timeout: Option<Duration>,
109 procedure_context: ProcedureContext,
110 },
111}
112
113#[allow(unused)]
114pub type GcTickerRef = Arc<GcTicker>;
115
116define_ticker!(
117 GcTicker,
119 event_type = Event,
120 event_value = Event::Tick
121);
122
123pub struct GcScheduler {
125 pub(crate) ctx: Arc<dyn SchedulerCtx>,
126 pub(crate) runtime_switch_manager: RuntimeSwitchManagerRef,
128 pub(crate) receiver: Receiver<Event>,
130 pub(crate) config: GcSchedulerOptions,
132 pub(crate) region_gc_tracker: Arc<Mutex<RegionGcTracker>>,
134 pub(crate) last_tracker_cleanup: Arc<Mutex<Instant>>,
136}
137
138impl GcScheduler {
139 pub(crate) fn new_with_config(
141 ctx: impl SchedulerCtx + 'static,
142 runtime_switch_manager: RuntimeSwitchManagerRef,
143 config: GcSchedulerOptions,
144 ) -> Result<(Self, GcTicker)> {
145 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 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 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 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 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 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 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(®ion_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}