Skip to main content

mito2/
gc.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
15//! GC worker which periodically checks and removes unused/obsolete  SST files.
16//!
17//! `expel time`: the time when the file is considered as removed, as in removed from the manifest.
18//! `lingering time`: the time duration before deleting files after they are removed from manifest.
19//! `delta manifest`: the manifest files after the last checkpoint that contains the changes to the manifest.
20//! `delete time`: the time when the file is actually deleted from the object store.
21//! `unknown files`: files that are not recorded in the manifest, usually due to saved checkpoint which remove actions before the checkpoint.
22//!
23
24use std::collections::{BTreeMap, HashMap, HashSet};
25use std::sync::Arc;
26use std::time::Duration;
27
28use common_meta::datanode::GcStat;
29use common_telemetry::tracing::Instrument as _;
30use common_telemetry::{debug, error, info, warn};
31use common_time::Timestamp;
32use itertools::Itertools;
33use object_store::{Entry, ErrorKind, Lister};
34use serde::{Deserialize, Serialize};
35use snafu::{ResultExt as _, ensure};
36use store_api::storage::{FileId, FileRef, FileRefsManifest, GcReport, IndexVersion, RegionId};
37use tokio::sync::{OwnedSemaphorePermit, TryAcquireError};
38use tokio_stream::StreamExt;
39
40use crate::access_layer::AccessLayerRef;
41use crate::cache::CacheManagerRef;
42use crate::cache::file_cache::FileType;
43use crate::config::MitoConfig;
44use crate::engine::region_hook::{RegionGcInfo, RegionHookRef};
45use crate::error::{
46    DurationOutOfRangeSnafu, InvalidRequestSnafu, JoinSnafu, OpenDalSnafu, Result,
47    TooManyGcJobsSnafu, UnexpectedSnafu,
48};
49use crate::manifest::action::{RegionManifest, RemovedFile};
50use crate::metrics::{
51    GC_DELETE_FILE_CNT, GC_DURATION_SECONDS, GC_ERRORS_TOTAL, GC_FILES_DELETED_TOTAL,
52    GC_ORPHANED_INDEX_FILES, GC_RUNS_TOTAL, GC_SKIPPED_UNPARSABLE_FILES,
53};
54use crate::region::{MitoRegionRef, RegionRoleState};
55use crate::sst::file::{RegionFileId, RegionIndexId, delete_files, delete_indexes};
56use crate::sst::location::{self};
57use crate::worker::DROPPING_MARKER_FILE;
58
59#[cfg(test)]
60mod worker_test;
61
62/// Helper function to determine if a file should be deleted based on common logic
63/// shared between Parquet and Puffin file types.
64fn should_delete_file(
65    is_in_manifest: bool,
66    is_in_tmp_ref: bool,
67    is_linger: bool,
68    is_eligible_for_delete: bool,
69    is_region_dropped: bool,
70    entry: &Entry,
71    unknown_file_may_linger_until: chrono::DateTime<chrono::Utc>,
72) -> bool {
73    if is_in_manifest || is_in_tmp_ref {
74        return false;
75    }
76
77    let is_known = is_linger || is_eligible_for_delete;
78    if is_known {
79        return is_eligible_for_delete;
80    }
81
82    // Unknown file: not in manifest, tmp_ref, or known removed records.
83    // For dropped regions, unknown files not protected by manifest/tmp refs/cross-region refs
84    // are deleted immediately. This relies on meta collecting FileRefsManifest from related
85    // active regions before issuing dropped-region GC; preserving young unknown files would
86    // also require keeping the table_repart tombstone for retry.
87    // For active/open regions, only delete if the object's last-modified time exceeds the
88    // unknown_file_lingering_time TTL.
89    if is_region_dropped {
90        return true;
91    }
92
93    entry
94        .metadata()
95        .last_modified()
96        .map(|ts| {
97            ts.into_inner().as_millisecond() < unknown_file_may_linger_until.timestamp_millis()
98        })
99        .unwrap_or(false)
100}
101
102/// Limit the amount of concurrent GC jobs on the datanode
103pub struct GcLimiter {
104    pub gc_job_limit: Arc<tokio::sync::Semaphore>,
105    gc_concurrency: usize,
106}
107
108pub type GcLimiterRef = Arc<GcLimiter>;
109
110impl GcLimiter {
111    pub fn new(gc_concurrency: usize) -> Self {
112        Self {
113            gc_job_limit: Arc::new(tokio::sync::Semaphore::new(gc_concurrency)),
114            gc_concurrency,
115        }
116    }
117
118    pub fn running_gc_tasks(&self) -> u32 {
119        (self.gc_concurrency - self.gc_job_limit.available_permits()) as u32
120    }
121
122    pub fn gc_concurrency(&self) -> u32 {
123        self.gc_concurrency as u32
124    }
125
126    pub fn gc_stat(&self) -> GcStat {
127        GcStat::new(self.running_gc_tasks(), self.gc_concurrency())
128    }
129
130    /// Try to acquire a permit for a GC job.
131    ///
132    /// If no permit is available, returns an `TooManyGcJobs` error.
133    pub fn permit(&self) -> Result<OwnedSemaphorePermit> {
134        self.gc_job_limit
135            .clone()
136            .try_acquire_owned()
137            .map_err(|e| match e {
138                TryAcquireError::Closed => UnexpectedSnafu {
139                    reason: format!("Failed to acquire gc permit: {e}"),
140                }
141                .build(),
142                TryAcquireError::NoPermits => TooManyGcJobsSnafu {}.build(),
143            })
144    }
145}
146
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(default)]
149pub struct GcConfig {
150    /// Whether GC is enabled.
151    pub enable: bool,
152    /// Lingering time before deleting files.
153    /// Should be long enough to allow long running queries to finish.
154    /// If set to None, then unused files will be deleted immediately.
155    ///
156    #[serde(with = "humantime_serde")]
157    pub lingering_time: Option<Duration>,
158    /// Lingering time before deleting unknown files(files with undetermine expel time).
159    /// expel time is the time when the file is considered as removed, as in removed from the manifest.
160    /// This should only occur rarely, as manifest keep tracks in `removed_files` field
161    /// unless something goes wrong.
162    ///
163    /// It is also the age threshold of the sweep that reclaims temporary files left behind
164    /// by a crashed HDFS writer, and that sweep runs even if `enable` is false. HDFS advances
165    /// the modification time of a file under construction only as its blocks complete, so
166    /// lowering this value also shrinks the margin between the sweep and files that are
167    /// still being written.
168    #[serde(with = "humantime_serde")]
169    pub unknown_file_lingering_time: Duration,
170    /// Maximum concurrent list operations per GC job.
171    /// This is used to limit the number of concurrent listing operations and speed up listing.
172    pub max_concurrent_lister_per_gc_job: usize,
173    /// Maximum concurrent GC jobs.
174    /// This is used to limit the number of concurrent GC jobs running on the datanode
175    /// to prevent too many concurrent GC jobs from overwhelming the datanode.
176    /// Setting it to 0 disables GC jobs and the temporary file sweep, which is limited
177    /// to the same number of concurrent tasks.
178    pub max_concurrent_gc_job: usize,
179}
180
181impl Default for GcConfig {
182    fn default() -> Self {
183        Self {
184            enable: false,
185            // expect long running queries to be finished(or at least be able to notify it's using a deleted file) within a reasonable time
186            lingering_time: Some(Duration::from_secs(60 * 60)),
187            // 1 day, for unknown expel time, which is when this file get removed from manifest.
188            // A long default avoids accidentally deleting pre-manifest files (e.g.
189            // compaction/flush still in progress) or temporary files of a writer that is
190            // still alive; the temporary file sweep uses this value as well.
191            unknown_file_lingering_time: Duration::from_secs(24 * 60 * 60),
192            max_concurrent_lister_per_gc_job: 32,
193            max_concurrent_gc_job: 4,
194        }
195    }
196}
197
198pub struct LocalGcWorker {
199    pub(crate) access_layer: AccessLayerRef,
200    pub(crate) cache_manager: Option<CacheManagerRef>,
201    pub(crate) regions: BTreeMap<RegionId, Option<MitoRegionRef>>,
202    /// Lingering time before deleting files.
203    pub(crate) opt: GcConfig,
204    /// Tmp ref files manifest, used to determine which files are still in use by ongoing queries.
205    ///
206    /// Also contains manifest versions of regions when the tmp ref files are generated.
207    /// Used to determine whether the tmp ref files are outdated.
208    pub(crate) file_ref_manifest: FileRefsManifest,
209    _permit: OwnedSemaphorePermit,
210    /// Whether to perform full file listing during GC.
211    /// When set to false, GC will only delete files that are tracked in the manifest's removed_files,
212    /// which can significantly improve performance by avoiding expensive list operations.
213    /// When set to true, GC will perform a full listing to find and delete orphan files
214    /// (files not tracked in the manifest).
215    ///
216    /// Set to false for regular GC operations to optimize performance.
217    /// Set to true periodically or when you need to clean up orphan files.
218    pub full_file_listing: bool,
219    /// The region hook (if any), fired via `on_region_gc` after each GC pass so
220    /// extensions with sidecar files outside the mito2 region dir can clean up.
221    pub(crate) hook: Option<RegionHookRef>,
222}
223
224pub struct ManifestOpenConfig {
225    pub compress_manifest: bool,
226    pub manifest_checkpoint_distance: u64,
227    pub experimental_manifest_keep_removed_file_count: usize,
228    pub experimental_manifest_keep_removed_file_ttl: Duration,
229}
230
231impl From<MitoConfig> for ManifestOpenConfig {
232    fn from(mito_config: MitoConfig) -> Self {
233        Self {
234            compress_manifest: mito_config.compress_manifest,
235            manifest_checkpoint_distance: mito_config.manifest_checkpoint_distance,
236            experimental_manifest_keep_removed_file_count: mito_config
237                .experimental_manifest_keep_removed_file_count,
238            experimental_manifest_keep_removed_file_ttl: mito_config
239                .experimental_manifest_keep_removed_file_ttl,
240        }
241    }
242}
243
244impl LocalGcWorker {
245    /// Create a new LocalGcWorker, with `regions_to_gc` regions to GC.
246    /// The regions are specified by their `RegionId` and should all belong to the same table.
247    ///
248    #[allow(clippy::too_many_arguments)]
249    pub async fn try_new(
250        access_layer: AccessLayerRef,
251        cache_manager: Option<CacheManagerRef>,
252        regions_to_gc: BTreeMap<RegionId, Option<MitoRegionRef>>,
253        opt: GcConfig,
254        file_ref_manifest: FileRefsManifest,
255        limiter: &GcLimiterRef,
256        full_file_listing: bool,
257        hook: Option<RegionHookRef>,
258    ) -> Result<Self> {
259        if let Some(first_region_id) = regions_to_gc.keys().next() {
260            let table_id = first_region_id.table_id();
261            for region_id in regions_to_gc.keys() {
262                ensure!(
263                    region_id.table_id() == table_id,
264                    InvalidRequestSnafu {
265                        region_id: *region_id,
266                        reason: format!(
267                            "Region {} does not belong to table {}",
268                            region_id, table_id
269                        ),
270                    }
271                );
272            }
273        }
274
275        let permit = limiter.permit()?;
276
277        Ok(Self {
278            access_layer,
279            cache_manager,
280            regions: regions_to_gc,
281            opt,
282            file_ref_manifest,
283            _permit: permit,
284            full_file_listing,
285            hook,
286        })
287    }
288
289    /// Get tmp ref files for all current regions
290    pub async fn read_tmp_ref_files(&self) -> Result<HashMap<RegionId, HashSet<FileRef>>> {
291        let mut tmp_ref_files = HashMap::new();
292        for (region_id, file_refs) in &self.file_ref_manifest.file_refs {
293            tmp_ref_files
294                .entry(*region_id)
295                .or_insert_with(HashSet::new)
296                .extend(file_refs.clone());
297            // no need to include manifest files here, as they are already included in region manifest
298        }
299
300        Ok(tmp_ref_files)
301    }
302
303    /// Run the GC worker in serial mode,
304    /// considering list files could be slow and run multiple regions in parallel
305    /// may cause too many concurrent listing operations.
306    ///
307    /// TODO(discord9): consider instead running in parallel mode
308    #[common_telemetry::tracing::instrument(
309        skip_all,
310        fields(region_count = self.regions.len(), full_file_listing = self.full_file_listing)
311    )]
312    pub async fn run(self) -> Result<GcReport> {
313        info!("LocalGcWorker started");
314        let _timer = GC_DURATION_SECONDS
315            .with_label_values(&["total"])
316            .start_timer();
317        let now = std::time::Instant::now();
318
319        let mut deleted_files = HashMap::new();
320        let mut deleted_indexes = HashMap::new();
321        let mut processed_regions = HashSet::new();
322        let mut need_retry_regions = HashSet::new();
323        let tmp_ref_files = self.read_tmp_ref_files().await?;
324        for (region_id, region) in &self.regions {
325            let per_region_time = std::time::Instant::now();
326            if region.as_ref().map(|r| r.manifest_ctx.current_state())
327                == Some(RegionRoleState::Follower)
328            {
329                return UnexpectedSnafu {
330                    reason: format!(
331                        "Region {} is in Follower state, should not run GC on follower regions",
332                        region_id
333                    ),
334                }
335                .fail();
336            }
337            let tmp_ref_files = tmp_ref_files
338                .get(region_id)
339                .cloned()
340                .unwrap_or_else(HashSet::new);
341            let outcome = self
342                .do_region_gc(*region_id, region.clone(), &tmp_ref_files)
343                .await?;
344            let RegionGcOutcome {
345                removed_files,
346                extension_cleanup_failed,
347            } = outcome;
348            let index_files = removed_files
349                .iter()
350                .filter_map(|f| f.index_version().map(|v| (f.file_id(), v)))
351                .collect_vec();
352            let data_files = removed_files
353                .into_iter()
354                .filter_map(|f| match f {
355                    RemovedFile::File(file_id, _) => Some(file_id),
356                    RemovedFile::Index(_, _) => None,
357                })
358                .collect();
359            // Don't acknowledge the region as processed until extension cleanup
360            // succeeds; retry it next pass instead.
361            if extension_cleanup_failed {
362                need_retry_regions.insert(*region_id);
363            } else {
364                deleted_files.insert(*region_id, data_files);
365                deleted_indexes.insert(*region_id, index_files);
366                processed_regions.insert(*region_id);
367            }
368            debug!(
369                "GC for region {} took {} secs.",
370                region_id,
371                per_region_time.elapsed().as_secs_f32()
372            );
373        }
374        info!(
375            "LocalGcWorker finished after {} secs.",
376            now.elapsed().as_secs_f32()
377        );
378        let report = GcReport {
379            deleted_files,
380            deleted_indexes,
381            need_retry_regions,
382            processed_regions,
383        };
384        Ok(report)
385    }
386}
387
388/// Per-region outcome of [`LocalGcWorker::do_region_gc`].
389///
390/// `extension_cleanup_failed` records whether a registered extension's
391/// [`RegionHook::on_region_gc`] could not finish; the caller must then keep the
392/// region un-acknowledged (retry set) so the next GC pass replays the callback.
393pub(crate) struct RegionGcOutcome {
394    /// Files physically deleted this pass.
395    pub removed_files: Vec<RemovedFile>,
396    /// `true` if a registered extension's `on_region_gc` returned `Err`; the
397    /// caller retries the region next pass.
398    pub extension_cleanup_failed: bool,
399}
400
401impl LocalGcWorker {
402    /// concurrency of listing files per region.
403    /// This is used to limit the number of concurrent listing operations and speed up listing
404    pub const CONCURRENCY_LIST_PER_FILES: usize = 1024;
405
406    /// Perform GC for the region.
407    /// 1. Get all the removed files in delta manifest files and their expel times
408    /// 2. List all files in the region dir concurrently
409    /// 3. Filter out the files that are still in use or may still be kept for a while
410    /// 4. Delete the unused files
411    ///
412    /// Note that the files that are still in use or may still be kept for a while are not deleted
413    /// to avoid deleting files that are still needed.
414    #[common_telemetry::tracing::instrument(
415        skip_all,
416        fields(
417            region_id = %region_id,
418            full_file_listing = self.full_file_listing,
419            region_present = region.is_some()
420        )
421    )]
422    pub(crate) async fn do_region_gc(
423        &self,
424        region_id: RegionId,
425        region: Option<MitoRegionRef>,
426        tmp_ref_files: &HashSet<FileRef>,
427    ) -> Result<RegionGcOutcome> {
428        let mode = if self.full_file_listing {
429            "full_listing"
430        } else {
431            "fast"
432        };
433        GC_RUNS_TOTAL.with_label_values(&[mode]).inc();
434        debug!(
435            "Doing gc for region {}, {}",
436            region_id,
437            if region.is_some() {
438                "region found"
439            } else {
440                "region not found, might be dropped"
441            }
442        );
443
444        ensure!(
445            region.is_some() || self.full_file_listing,
446            InvalidRequestSnafu {
447                region_id,
448                reason: "region not found and full_file_listing is false; refusing GC without full listing".to_string(),
449            }
450        );
451
452        let manifest = if let Some(region) = &region {
453            let manifest = region.manifest_ctx.manifest().await;
454            // If the manifest version does not match, skip GC for this region to avoid deleting files that are still in use.
455            let file_ref_manifest_version = self
456                .file_ref_manifest
457                .manifest_version
458                .get(&region.region_id())
459                .cloned();
460            if file_ref_manifest_version != Some(manifest.manifest_version) {
461                // should be rare enough(few seconds after leader update manifest version), just skip gc for this region
462                warn!(
463                    "Tmp ref files manifest version {:?} does not match region {} manifest version {}, skip gc for this region",
464                    file_ref_manifest_version,
465                    region.region_id(),
466                    manifest.manifest_version
467                );
468                GC_ERRORS_TOTAL
469                    .with_label_values(&["manifest_mismatch"])
470                    .inc();
471                return Ok(RegionGcOutcome {
472                    removed_files: vec![],
473                    extension_cleanup_failed: false,
474                });
475            }
476            Some(manifest)
477        } else {
478            None
479        };
480
481        let all_entries = if let Some(manifest) = &manifest
482            && self.full_file_listing
483        {
484            // do the time consuming listing only when full_file_listing is true(and region is open)
485            // and do it first to make sure we have the latest manifest etc.
486            self.list_from_object_store(region_id, manifest.files.len())
487                .await?
488        } else if manifest.is_none() && self.full_file_listing {
489            // if region is already dropped, we have no manifest to refer to,
490            // so only do gc if `full_file_listing` is true, otherwise just skip it
491            // TODO(discord9): is doing one serial listing enough here?
492            self.list_from_object_store(region_id, Self::CONCURRENCY_LIST_PER_FILES)
493                .await?
494        } else {
495            vec![]
496        };
497
498        let recently_removed_files = if let Some(manifest) = &manifest {
499            self.get_removed_files_expel_times(manifest).await?
500        } else {
501            Default::default()
502        };
503
504        if recently_removed_files.is_empty() {
505            // no files to remove, skip
506            debug!("No recently removed files to gc for region {}", region_id);
507        }
508
509        let removed_file_cnt = recently_removed_files
510            .values()
511            .map(|s| s.len())
512            .sum::<usize>();
513
514        let current_files = manifest.as_ref().map(|m| &m.files);
515
516        let in_manifest = if let Some(current_files) = current_files {
517            current_files
518                .iter()
519                .map(|(file_id, meta)| (*file_id, meta.index_version()))
520                .collect::<HashMap<_, _>>()
521        } else {
522            Default::default()
523        };
524
525        let is_region_dropped = region.is_none();
526
527        let in_tmp_ref = tmp_ref_files
528            .iter()
529            .map(|file_ref| (file_ref.file_id, file_ref.index_version))
530            .collect::<HashSet<_>>();
531
532        let deletable_files = self
533            .list_to_be_deleted_files(
534                region_id,
535                is_region_dropped,
536                &in_manifest,
537                &in_tmp_ref,
538                recently_removed_files,
539                all_entries,
540            )
541            .await?;
542
543        let unused_file_cnt = deletable_files.len();
544
545        info!(
546            "gc: for region{}{region_id}: In manifest file cnt: {}, Tmp ref file cnt: {}, recently removed files: {}, Unused files to delete count: {}",
547            if region.is_none() {
548                "(region dropped)"
549            } else {
550                ""
551            },
552            current_files.map(|c| c.len()).unwrap_or(0),
553            tmp_ref_files.len(),
554            removed_file_cnt,
555            deletable_files.len(),
556        );
557        debug!(
558            "gc: deletable files for region {}: {:?}",
559            region_id, &deletable_files
560        );
561
562        debug!(
563            "Found {} unused index files to delete for region {}",
564            deletable_files.len(),
565            region_id
566        );
567
568        let _delete_timer = GC_DURATION_SECONDS
569            .with_label_values(&["delete_files"])
570            .start_timer();
571        self.delete_files(region_id, &deletable_files).await?;
572
573        debug!(
574            "Successfully deleted {} unused files for region {}",
575            unused_file_cnt, region_id
576        );
577
578        // Notify extensions so they can clean up sidecar files. Fire when there
579        // are removed files, or on a full-listing pass (lets extensions reconcile
580        // orphans even when mito deleted nothing). Cleanup is always scoped to
581        // `removed_files`; see `RegionGcInfo`.
582        let extension_cleanup_failed = if let Some(hook) = &self.hook
583            && (!deletable_files.is_empty() || self.full_file_listing)
584        {
585            let region_metadata = region.as_ref().map(|r| r.metadata());
586            let result = hook
587                .on_region_gc(
588                    region_id,
589                    region_metadata.as_ref(),
590                    &self.access_layer,
591                    &RegionGcInfo {
592                        removed_files: &deletable_files,
593                        is_region_dropped,
594                        full_file_listing: self.full_file_listing,
595                    },
596                )
597                .await;
598            if let Err(err) = result {
599                warn!(
600                    err;
601                    "Region hook on_region_gc failed for region {}, will retry on the next GC pass",
602                    region_id
603                );
604                true
605            } else {
606                false
607            }
608        } else {
609            false
610        };
611
612        // Defer clearing the manifest tracking until the extension succeeds, so
613        // a failed live-region cleanup is replayed next pass. (Dropped regions
614        // have no manifest.)
615        if !extension_cleanup_failed && let Some(region) = &region {
616            let _update_timer = GC_DURATION_SECONDS
617                .with_label_values(&["update_manifest"])
618                .start_timer();
619            self.update_manifest_removed_files(region, deletable_files.clone())
620                .await?;
621        }
622
623        Ok(RegionGcOutcome {
624            removed_files: deletable_files,
625            extension_cleanup_failed,
626        })
627    }
628
629    #[common_telemetry::tracing::instrument(
630        skip_all,
631        fields(region_id = %region_id, removed_file_count = removed_files.len())
632    )]
633    async fn delete_files(&self, region_id: RegionId, removed_files: &[RemovedFile]) -> Result<()> {
634        let mut index_ids = vec![];
635        let file_pairs = removed_files
636            .iter()
637            .filter_map(|f| match f {
638                RemovedFile::File(file_id, v) => Some((*file_id, v.unwrap_or(0))),
639                RemovedFile::Index(file_id, index_version) => {
640                    let region_index_id =
641                        RegionIndexId::new(RegionFileId::new(region_id, *file_id), *index_version);
642                    index_ids.push(region_index_id);
643                    None
644                }
645            })
646            .collect_vec();
647        delete_files(
648            region_id,
649            &file_pairs,
650            true,
651            &self.access_layer,
652            &self.cache_manager,
653        )
654        .await?;
655
656        if !file_pairs.is_empty() {
657            let deleted_count = file_pairs.len() as u64;
658            GC_FILES_DELETED_TOTAL
659                .with_label_values(&["parquet"])
660                .inc_by(deleted_count);
661            GC_DELETE_FILE_CNT.inc_by(deleted_count);
662        }
663
664        if !index_ids.is_empty() {
665            let deleted_count = index_ids.len() as u64;
666            delete_indexes(&index_ids, &self.access_layer, &self.cache_manager)
667                .await
668                .inspect_err(|_| {
669                    GC_ERRORS_TOTAL.with_label_values(&["delete_failed"]).inc();
670                })?;
671            GC_FILES_DELETED_TOTAL
672                .with_label_values(&["index"])
673                .inc_by(deleted_count);
674            GC_DELETE_FILE_CNT.inc_by(deleted_count);
675        }
676
677        Ok(())
678    }
679
680    /// Update region manifest for clear the actually deleted files
681    #[common_telemetry::tracing::instrument(
682        skip_all,
683        fields(region_id = %region.region_id(), deleted_file_count = deleted_files.len())
684    )]
685    async fn update_manifest_removed_files(
686        &self,
687        region: &MitoRegionRef,
688        deleted_files: Vec<RemovedFile>,
689    ) -> Result<()> {
690        let deleted_file_cnt = deleted_files.len();
691        debug!(
692            "Trying to update manifest for {deleted_file_cnt} removed files for region {}",
693            region.region_id()
694        );
695
696        let mut manager = region.manifest_ctx.manifest_manager.write().await;
697        let cnt = deleted_files.len();
698        manager.clear_deleted_files(deleted_files);
699        debug!(
700            "Updated region_id={} region manifest to clear {cnt} deleted files",
701            region.region_id(),
702        );
703
704        Ok(())
705    }
706
707    /// Get all the removed files in delta manifest files and their expel times.
708    /// The expel time is the time when the file is considered as removed.
709    /// Which is the last modified time of delta manifest which contains the remove action.
710    ///
711    pub async fn get_removed_files_expel_times(
712        &self,
713        region_manifest: &Arc<RegionManifest>,
714    ) -> Result<BTreeMap<Timestamp, HashSet<RemovedFile>>> {
715        let mut ret = BTreeMap::new();
716        for files in &region_manifest.removed_files.removed_files {
717            let expel_time = Timestamp::new_millisecond(files.removed_at);
718            let set = ret.entry(expel_time).or_insert_with(HashSet::new);
719            set.extend(files.files.iter().cloned());
720        }
721
722        Ok(ret)
723    }
724
725    /// Create partitioned listers for concurrent file listing based on concurrency level.
726    /// Returns a vector of (lister, end_boundary) pairs for parallel processing.
727    async fn partition_region_files(
728        &self,
729        region_id: RegionId,
730        concurrency: usize,
731    ) -> Result<Vec<(Lister, Option<String>)>> {
732        let region_dir = self.access_layer.build_region_dir(region_id);
733
734        let partitions = gen_partition_from_concurrency(concurrency);
735        let bounds = vec![None]
736            .into_iter()
737            .chain(partitions.iter().map(|p| Some(p.clone())))
738            .chain(vec![None])
739            .collect::<Vec<_>>();
740
741        let mut listers = vec![];
742        for part in bounds.windows(2) {
743            let start = part[0].clone();
744            let end = part[1].clone();
745            let mut lister = self.access_layer.object_store().lister_with(&region_dir);
746            if let Some(s) = start {
747                lister = lister.start_after(&s);
748            }
749
750            let lister = lister.await.context(OpenDalSnafu)?;
751            listers.push((lister, end));
752        }
753
754        Ok(listers)
755    }
756
757    /// List all files in the region directory.
758    /// Returns a vector of all file entries found.
759    /// This might take a long time if there are many files in the region directory.
760    #[common_telemetry::tracing::instrument(
761        skip_all,
762        fields(region_id = %region_id, file_cnt_hint = file_cnt_hint)
763    )]
764    async fn list_from_object_store(
765        &self,
766        region_id: RegionId,
767        file_cnt_hint: usize,
768    ) -> Result<Vec<Entry>> {
769        let _timer = GC_DURATION_SECONDS
770            .with_label_values(&["list_files"])
771            .start_timer();
772        let start = tokio::time::Instant::now();
773        let concurrency = (file_cnt_hint / Self::CONCURRENCY_LIST_PER_FILES)
774            .max(1)
775            .min(self.opt.max_concurrent_lister_per_gc_job);
776
777        let listers = self
778            .partition_region_files(region_id, concurrency)
779            .await
780            .inspect_err(|_| {
781                GC_ERRORS_TOTAL.with_label_values(&["list_failed"]).inc();
782            })?;
783        let lister_cnt = listers.len();
784
785        // Step 2: Concurrently list all parquet files in the region root directory
786        let mut all_entries = self
787            .list_region_files_concurrent(listers)
788            .await
789            .inspect_err(|_| {
790                GC_ERRORS_TOTAL.with_label_values(&["list_failed"]).inc();
791            })?;
792        let root_cnt = all_entries.len();
793
794        // Step 2b: Flat-list region_dir/index/ for puffin files.
795        // This is NOT a recursive listing — we only list the index/
796        // subdirectory to avoid scanning nested dirs/staging/blob/cache.
797        let index_entries = self
798            .list_region_index_files(region_id)
799            .await
800            .inspect_err(|_| {
801                GC_ERRORS_TOTAL.with_label_values(&["list_failed"]).inc();
802            })?;
803        let index_cnt = index_entries.len();
804        all_entries.extend(index_entries);
805        info!(
806            "gc: full listing mode cost {} secs using {lister_cnt} lister for root={root_cnt} index={index_cnt} files in region {}.",
807            start.elapsed().as_secs_f64(),
808            region_id
809        );
810        Ok(all_entries)
811    }
812
813    /// Flat-list puffin files from `region_dir/index/`.
814    /// If the index directory does not exist, returns an empty vec without error.
815    /// Only `.puffin` files (not subdirectories) are included.
816    async fn list_region_index_files(&self, region_id: RegionId) -> Result<Vec<Entry>> {
817        let region_dir = self.access_layer.build_region_dir(region_id);
818        let index_dir = location::index_dir(&region_dir);
819
820        let mut lister = match self
821            .access_layer
822            .object_store()
823            .lister_with(&index_dir)
824            .await
825        {
826            Ok(l) => l,
827            Err(e) if e.kind() == ErrorKind::NotFound => {
828                // Index dir may not exist — that's fine, just log and return empty.
829                // object-store backends (especially filesystem) may error on
830                // non-existent directories.
831                debug!(
832                    "Index directory not found for region {}: {}. Treating as empty.",
833                    region_id, e
834                );
835                return Ok(vec![]);
836            }
837            Err(e) => return Err(e).context(OpenDalSnafu),
838        };
839
840        let mut entries = Vec::new();
841        while let Some(entry) = lister.next().await {
842            let entry = entry.context(OpenDalSnafu)?;
843            if entry.metadata().is_file() && entry.name().ends_with(".puffin") {
844                entries.push(entry);
845            }
846        }
847
848        Ok(entries)
849    }
850
851    /// Concurrently list all files in the region directory using the provided listers.
852    /// Returns a vector of all file entries found across all partitions.
853    async fn list_region_files_concurrent(
854        &self,
855        listers: Vec<(Lister, Option<String>)>,
856    ) -> Result<Vec<Entry>> {
857        let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
858        let mut handles = vec![];
859
860        for (lister, end) in listers {
861            let tx = tx.clone();
862            let handle = tokio::spawn(
863                async move {
864                    let stream = lister.take_while(|e: &std::result::Result<Entry, _>| match e {
865                        Ok(e) => {
866                            if let Some(end) = &end {
867                                // reach end, stop listing
868                                e.name() < end.as_str()
869                            } else {
870                                // no end, take all entries
871                                true
872                            }
873                        }
874                        // Entry went wrong. Keep listing so the error can be propagated below
875                        // instead of returning a partial listing as success.
876                        Err(err) => {
877                            warn!("Failed to list entry: {}", err);
878                            true
879                        }
880                    });
881                    let stream = stream
882                        .filter(|e| {
883                            if let Ok(e) = &e {
884                                // notice that we only care about files, skip dirs
885                                e.metadata().is_file()
886                            } else {
887                                // error entry, take for further logging
888                                true
889                            }
890                        })
891                        .collect::<Vec<_>>()
892                        .await;
893                    // ordering of files doesn't matter here, so we can send them directly
894                    tx.send(stream).await.expect("Failed to send entries");
895                }
896                .instrument(common_telemetry::tracing::info_span!("gc_list_partition")),
897            );
898
899            handles.push(handle);
900        }
901
902        // Wait for all listers to finish
903        for handle in handles {
904            handle.await.context(JoinSnafu)?;
905        }
906
907        drop(tx); // Close the channel to stop receiving
908
909        // Collect all entries from the channel
910        let mut all_entries = vec![];
911        while let Some(stream) = rx.recv().await {
912            for entry in stream {
913                all_entries.push(entry.context(OpenDalSnafu)?);
914            }
915        }
916
917        Ok(all_entries)
918    }
919
920    #[allow(clippy::too_many_arguments)]
921    fn filter_deletable_files(
922        &self,
923        is_region_dropped: bool,
924        entries: Vec<Entry>,
925        in_manifest: &HashMap<FileId, Option<IndexVersion>>,
926        in_tmp_ref: &HashSet<(FileId, Option<IndexVersion>)>,
927        may_linger_files: &HashSet<&RemovedFile>,
928        eligible_for_delete: &HashSet<&RemovedFile>,
929        unknown_file_may_linger_until: chrono::DateTime<chrono::Utc>,
930    ) -> Vec<RemovedFile> {
931        let mut ready_for_delete = vec![];
932        // Every index-version reader needs the same Parquet file.
933        let referenced_ssts: HashSet<_> = in_tmp_ref.iter().map(|(file_id, _)| *file_id).collect();
934
935        let may_linger_files: HashMap<FileId, HashSet<&RemovedFile>> = may_linger_files
936            .iter()
937            .fold(HashMap::new(), |mut acc, file| {
938                let indices = acc.entry(file.file_id()).or_default();
939                indices.insert(file);
940                acc
941            });
942
943        let eligible_for_delete: HashMap<FileId, HashSet<&RemovedFile>> = eligible_for_delete
944            .iter()
945            .fold(HashMap::new(), |mut acc, file| {
946                let indices = acc.entry(file.file_id()).or_default();
947                indices.insert(file);
948                acc
949            });
950
951        for entry in entries {
952            if entry.name() == DROPPING_MARKER_FILE {
953                continue;
954            }
955
956            let (file_id, file_type) = match location::parse_file_id_type_from_path(entry.name()) {
957                Ok((file_id, file_type)) => (file_id, file_type),
958                Err(err) => {
959                    error!(err; "Failed to parse file id from path: {}", entry.name());
960                    // if we can't parse the file id, it means it's not a sst or index file
961                    // shouldn't delete it because we don't know what it is
962                    GC_SKIPPED_UNPARSABLE_FILES.inc();
963                    continue;
964                }
965            };
966
967            let should_delete = match file_type {
968                FileType::Parquet => {
969                    let is_in_manifest = in_manifest.contains_key(&file_id);
970                    let is_in_tmp_ref = referenced_ssts.contains(&file_id);
971                    let is_linger = may_linger_files.contains_key(&file_id);
972                    let is_eligible_for_delete = eligible_for_delete.contains_key(&file_id);
973
974                    should_delete_file(
975                        is_in_manifest,
976                        is_in_tmp_ref,
977                        is_linger,
978                        is_eligible_for_delete,
979                        is_region_dropped,
980                        &entry,
981                        unknown_file_may_linger_until,
982                    )
983                }
984                FileType::Puffin(version) => {
985                    let is_in_manifest = in_manifest.get(&file_id) == Some(&Some(version));
986                    let is_in_tmp_ref = in_tmp_ref.contains(&(file_id, Some(version)));
987                    let is_linger = may_linger_files
988                        .get(&file_id)
989                        .map(|files| files.contains(&&RemovedFile::Index(file_id, version)))
990                        .unwrap_or(false);
991                    let is_eligible_for_delete = eligible_for_delete
992                        .get(&file_id)
993                        .map(|files| files.contains(&&RemovedFile::Index(file_id, version)))
994                        .unwrap_or(false);
995
996                    should_delete_file(
997                        is_in_manifest,
998                        is_in_tmp_ref,
999                        is_linger,
1000                        is_eligible_for_delete,
1001                        is_region_dropped,
1002                        &entry,
1003                        unknown_file_may_linger_until,
1004                    )
1005                }
1006            };
1007
1008            if should_delete {
1009                let removed_file = match file_type {
1010                    FileType::Parquet => {
1011                        // notice this cause we don't track index version for parquet files
1012                        // since entries comes from listing, we can't get index version from path
1013                        RemovedFile::File(file_id, None)
1014                    }
1015                    FileType::Puffin(version) => {
1016                        GC_ORPHANED_INDEX_FILES.inc();
1017                        RemovedFile::Index(file_id, version)
1018                    }
1019                };
1020                ready_for_delete.push(removed_file);
1021            }
1022        }
1023        ready_for_delete
1024    }
1025
1026    /// List files to be deleted based on their presence in the manifest, temporary references, and recently removed files.
1027    /// Returns a vector of `RemovedFile` that are eligible for deletion.
1028    ///
1029    /// When `full_file_listing` is false, this method will only delete (subset of) files tracked in
1030    /// `recently_removed_files`, which significantly
1031    /// improves performance. When `full_file_listing` is true, it read from `all_entries` to find
1032    /// and delete orphan files (files not tracked in the manifest).
1033    ///
1034    pub async fn list_to_be_deleted_files(
1035        &self,
1036        region_id: RegionId,
1037        is_region_dropped: bool,
1038        in_manifest: &HashMap<FileId, Option<IndexVersion>>,
1039        in_tmp_ref: &HashSet<(FileId, Option<IndexVersion>)>,
1040        recently_removed_files: BTreeMap<Timestamp, HashSet<RemovedFile>>,
1041        all_entries: Vec<Entry>,
1042    ) -> Result<Vec<RemovedFile>> {
1043        let now = chrono::Utc::now();
1044        let may_linger_until = self
1045            .opt
1046            .lingering_time
1047            .map(|lingering_time| {
1048                chrono::Duration::from_std(lingering_time)
1049                    .with_context(|_| DurationOutOfRangeSnafu {
1050                        input: lingering_time,
1051                    })
1052                    .map(|t| now - t)
1053            })
1054            .transpose()?;
1055
1056        let unknown_file_may_linger_until = now
1057            - chrono::Duration::from_std(self.opt.unknown_file_lingering_time).with_context(
1058                |_| DurationOutOfRangeSnafu {
1059                    input: self.opt.unknown_file_lingering_time,
1060                },
1061            )?;
1062
1063        // files that may linger, which means they are not in use but may still be kept for a while
1064        let threshold =
1065            may_linger_until.map(|until| Timestamp::new_millisecond(until.timestamp_millis()));
1066        // TODO(discord9): if region is already closed, maybe handle threshold differently?
1067        // is consider all files to be recently removed acceptable?
1068        let mut recently_removed_files = recently_removed_files;
1069        let may_linger_files = match threshold {
1070            Some(threshold) => recently_removed_files.split_off(&threshold),
1071            None => BTreeMap::new(),
1072        };
1073        debug!("may_linger_files: {:?}", may_linger_files);
1074
1075        let all_may_linger_files = may_linger_files.values().flatten().collect::<HashSet<_>>();
1076
1077        // known files(tracked in removed files field) that are eligible for removal
1078        // (passed lingering time)
1079        let eligible_for_removal = recently_removed_files
1080            .values()
1081            .flatten()
1082            .collect::<HashSet<_>>();
1083
1084        // When full_file_listing is false, skip expensive list operations and only delete
1085        // files that are tracked in recently_removed_files
1086        if !self.full_file_listing {
1087            let referenced_ssts: HashSet<_> =
1088                in_tmp_ref.iter().map(|(file_id, _)| *file_id).collect();
1089            // Only delete files that:
1090            // 1. Are in recently_removed_files (tracked in manifest)
1091            // 2. Are not in use(in manifest or tmp ref)
1092            // 3. Have passed the lingering time
1093            let files_to_delete: Vec<RemovedFile> = eligible_for_removal
1094                .iter()
1095                .filter(|file| {
1096                    let in_use = match file {
1097                        RemovedFile::File(file_id, _) => {
1098                            in_manifest.contains_key(file_id) || referenced_ssts.contains(file_id)
1099                        }
1100                        RemovedFile::Index(file_id, version) => {
1101                            in_manifest.get(file_id) == Some(&Some(*version))
1102                                || in_tmp_ref.contains(&(*file_id, Some(*version)))
1103                        }
1104                    };
1105                    !in_use
1106                })
1107                .map(|&f| f.clone())
1108                .collect();
1109
1110            info!(
1111                "gc: fast mode (no full listing) for region {}, found {} files to delete from manifest",
1112                region_id,
1113                files_to_delete.len()
1114            );
1115
1116            return Ok(files_to_delete);
1117        }
1118
1119        // Full file listing mode: get the full list of files from object store
1120
1121        // Step 3: Filter files to determine which ones can be deleted
1122        let all_unused_files_ready_for_delete = self.filter_deletable_files(
1123            is_region_dropped,
1124            all_entries,
1125            in_manifest,
1126            in_tmp_ref,
1127            &all_may_linger_files,
1128            &eligible_for_removal,
1129            unknown_file_may_linger_until,
1130        );
1131
1132        Ok(all_unused_files_ready_for_delete)
1133    }
1134}
1135
1136/// Generate partition prefixes based on concurrency and
1137/// assume file names are evenly-distributed uuid string,
1138/// to evenly distribute files across partitions.
1139/// For example, if concurrency is 2, partition prefixes will be:
1140/// ["8"] so it divide uuids into two partitions based on the first character.
1141/// If concurrency is 32, partition prefixes will be:
1142/// ["08", "10", "18", "20", "28", "30", "38" ..., "f0", "f8"]
1143/// if concurrency is 1, it returns an empty vector.
1144///
1145fn gen_partition_from_concurrency(concurrency: usize) -> Vec<String> {
1146    let n = concurrency.next_power_of_two();
1147    if n <= 1 {
1148        return vec![];
1149    }
1150
1151    // `d` is the number of hex characters required to build the partition key.
1152    // `p` is the total number of possible values for a key of length `d`.
1153    // We need to find the smallest `d` such that 16^d >= n.
1154    let mut d = 0;
1155    let mut p: u128 = 1;
1156    while p < n as u128 {
1157        p *= 16;
1158        d += 1;
1159    }
1160
1161    let total_space = p;
1162    let step = total_space / n as u128;
1163
1164    (1..n)
1165        .map(|i| {
1166            let boundary = i as u128 * step;
1167            format!("{:0width$x}", boundary, width = d)
1168        })
1169        .collect()
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    #[test]
1177    fn test_gen_partition_from_concurrency() {
1178        let partitions = gen_partition_from_concurrency(1);
1179        assert!(partitions.is_empty());
1180
1181        let partitions = gen_partition_from_concurrency(2);
1182        assert_eq!(partitions, vec!["8"]);
1183
1184        let partitions = gen_partition_from_concurrency(3);
1185        assert_eq!(partitions, vec!["4", "8", "c"]);
1186
1187        let partitions = gen_partition_from_concurrency(4);
1188        assert_eq!(partitions, vec!["4", "8", "c"]);
1189
1190        let partitions = gen_partition_from_concurrency(8);
1191        assert_eq!(partitions, vec!["2", "4", "6", "8", "a", "c", "e"]);
1192
1193        let partitions = gen_partition_from_concurrency(16);
1194        assert_eq!(
1195            partitions,
1196            vec![
1197                "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"
1198            ]
1199        );
1200
1201        let partitions = gen_partition_from_concurrency(32);
1202        assert_eq!(
1203            partitions,
1204            [
1205                "08", "10", "18", "20", "28", "30", "38", "40", "48", "50", "58", "60", "68", "70",
1206                "78", "80", "88", "90", "98", "a0", "a8", "b0", "b8", "c0", "c8", "d0", "d8", "e0",
1207                "e8", "f0", "f8",
1208            ]
1209        );
1210    }
1211
1212    #[test]
1213    fn test_gc_config_default_lingering_time() {
1214        assert_eq!(
1215            GcConfig::default().lingering_time,
1216            Some(Duration::from_secs(60 * 60))
1217        );
1218    }
1219}