Skip to main content

lfsx_server/storage/
staging.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use tokio::fs;
5
6use super::LocalStore;
7use super::sweep::age;
8
9#[derive(Debug, Default, PartialEq, Eq)]
10pub struct Reclaimed {
11    pub files: u64,
12    pub bytes: u64,
13}
14
15impl LocalStore {
16    // An upload streams into `<oid>.<n>.part` and renames on success. Every error
17    // path the handler controls removes it, but a kill or a host crash mid-transfer
18    // leaves one behind for good, inside the object fanout where it is easy to
19    // mistake for an object.
20    //
21    // `older_than` has to exceed the longest transfer the server could plausibly
22    // still be serving: a .part file younger than that is not litter, it is an
23    // upload in flight, and removing it would break a client doing nothing wrong.
24    pub async fn reclaim_staging(&self, older_than: Duration) -> Reclaimed {
25        let mut reclaimed = Reclaimed::default();
26        let mut directories = vec![self.root.clone()];
27
28        while let Some(directory) = directories.pop() {
29            let Ok(mut entries) = fs::read_dir(&directory).await else {
30                continue;
31            };
32
33            while let Ok(Some(entry)) = entries.next_entry().await {
34                let path = entry.path();
35                let Ok(metadata) = entry.metadata().await else {
36                    continue;
37                };
38
39                if metadata.is_dir() {
40                    // Staging files only ever appear under org/repo/xx/yy, so the
41                    // shared .content store and .locks hold none — walking them
42                    // every hour would cost I/O that grows with the whole store
43                    // instead of with the litter. Only the root carries those:
44                    // deeper down, a repository really can be named .github.
45                    if directory != self.root || !is_dotted(&path) {
46                        directories.push(path);
47                    }
48                    continue;
49                }
50
51                let is_staging = path
52                    .extension()
53                    .is_some_and(|extension| extension == "part");
54                if !is_staging || age(&metadata) < older_than {
55                    continue;
56                }
57
58                if fs::remove_file(&path).await.is_ok() {
59                    reclaimed.files += 1;
60                    reclaimed.bytes += metadata.len();
61                }
62            }
63        }
64
65        reclaimed
66    }
67}
68
69fn is_dotted(path: &Path) -> bool {
70    path.file_name()
71        .is_some_and(|name| name.to_string_lossy().starts_with('.'))
72}
73
74pub async fn reclaim(root: PathBuf, older_than: Duration) {
75    let reclaimed = LocalStore::new(root).reclaim_staging(older_than).await;
76
77    if reclaimed.files > 0 {
78        tracing::info!(
79            files = reclaimed.files,
80            bytes = reclaimed.bytes,
81            "reclaimed staging files left by interrupted uploads"
82        );
83    }
84}
85
86#[cfg(test)]
87mod tests;