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