Skip to main content

lfsx_server/storage/
sweep.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::Ordering;
4use std::time::{Duration, Instant, SystemTime};
5
6use serde::Serialize;
7use tokio::fs;
8
9use super::LocalStore;
10use crate::error::Error;
11use crate::namespace::Namespace;
12
13#[derive(Debug, Default, Serialize)]
14pub struct SweepReport {
15    pub swept: usize,
16    pub bytes: u64,
17    pub within_grace: usize,
18    pub dry_run: bool,
19}
20
21impl LocalStore {
22    pub async fn sweep(
23        &self,
24        ns: &Namespace,
25        retained: &HashSet<String>,
26        grace: Duration,
27        dry_run: bool,
28    ) -> Result<SweepReport, Error> {
29        let mut report = SweepReport {
30            dry_run,
31            ..SweepReport::default()
32        };
33
34        let Ok(mut prefixes) = fs::read_dir(self.root.join(ns.org()).join(ns.repo())).await else {
35            return Ok(report);
36        };
37
38        while let Some(prefix) = prefixes.next_entry().await? {
39            let Ok(mut fanouts) = fs::read_dir(prefix.path()).await else {
40                continue;
41            };
42
43            while let Some(fanout) = fanouts.next_entry().await? {
44                self.sweep_directory(&fanout.path(), ns, retained, grace, &mut report)
45                    .await?;
46            }
47
48            if !dry_run {
49                let _ = fs::remove_dir(prefix.path()).await;
50            }
51        }
52
53        Ok(report)
54    }
55
56    // Is this object still linked from a repository other than the one being
57    // swept? Reads the tree rather than the link count, because nlink is not
58    // portable and the number of repositories is small.
59    async fn referenced_elsewhere(&self, oid: &str, sweeping: &Namespace) -> bool {
60        let Ok(mut orgs) = fs::read_dir(&self.root).await else {
61            return false;
62        };
63
64        while let Ok(Some(org)) = orgs.next_entry().await {
65            let org_name = org.file_name().to_string_lossy().into_owned();
66            if org_name.starts_with('.') {
67                continue;
68            }
69
70            let Ok(mut repos) = fs::read_dir(org.path()).await else {
71                continue;
72            };
73
74            while let Ok(Some(repo)) = repos.next_entry().await {
75                let repo_name = repo.file_name().to_string_lossy().into_owned();
76                if org_name == sweeping.org() && repo_name == sweeping.repo() {
77                    continue;
78                }
79
80                let candidate = repo.path().join(&oid[0..2]).join(&oid[2..4]).join(oid);
81                if fs::metadata(candidate).await.is_ok() {
82                    return true;
83                }
84            }
85        }
86
87        false
88    }
89
90    async fn sweep_directory(
91        &self,
92        directory: &Path,
93        ns: &Namespace,
94        retained: &HashSet<String>,
95        grace: Duration,
96        report: &mut SweepReport,
97    ) -> Result<(), Error> {
98        let Ok(mut entries) = fs::read_dir(directory).await else {
99            return Ok(());
100        };
101
102        while let Some(entry) = entries.next_entry().await? {
103            let name = entry.file_name().to_string_lossy().into_owned();
104            if Self::validate_oid(&name).is_err() || retained.contains(&name) {
105                continue;
106            }
107
108            let metadata = entry.metadata().await?;
109            if age(&metadata) < grace {
110                report.within_grace += 1;
111                continue;
112            }
113
114            report.swept += 1;
115
116            if report.dry_run {
117                // A dry run must not count bytes another repository still holds,
118                // or it promises space it cannot free.
119                if !self.referenced_elsewhere(&name, ns).await {
120                    report.bytes += metadata.len();
121                }
122                continue;
123            }
124
125            fs::remove_file(entry.path()).await?;
126
127            // The bytes live once under .content, linked from each repository
128            // that holds them. Dropping this repository's link frees nothing
129            // until the last one goes, so only then is it counted as freed.
130            if !self.referenced_elsewhere(&name, ns).await {
131                let content = self.content_path(&name);
132                let size = fs::metadata(&content)
133                    .await
134                    .map(|content_metadata| content_metadata.len())
135                    .unwrap_or_else(|_| metadata.len());
136
137                // Count the bytes only if this call is the one that removed
138                // them. Two repositories dropping their last reference at the
139                // same time would otherwise each claim the same space, and two
140                // reports would add up to more than the disk ever held.
141                if fs::remove_file(&content).await.is_ok() {
142                    report.bytes += size;
143                }
144            }
145        }
146
147        if !report.dry_run {
148            let _ = fs::remove_dir(directory).await;
149        }
150
151        Ok(())
152    }
153}
154
155fn age(metadata: &std::fs::Metadata) -> Duration {
156    metadata
157        .modified()
158        .ok()
159        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
160        .unwrap_or_default()
161}
162
163const USAGE_TTL: Duration = Duration::from_secs(60);
164
165impl LocalStore {
166    pub async fn usage(&self) -> (u64, u64) {
167        let mut cached = self.usage.lock().await;
168
169        if let Some((measured_at, objects, bytes)) = *cached
170            && measured_at.elapsed() < USAGE_TTL
171        {
172            return (objects, bytes);
173        }
174
175        let measured = self.measure().await;
176        *cached = Some((Instant::now(), measured.0, measured.1));
177
178        measured
179    }
180
181    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
182        let key = ns.to_string();
183        let mut cached = self.per_namespace.lock().await;
184
185        if let Some((measured_at, objects, bytes)) = cached.get(&key)
186            && measured_at.elapsed() < USAGE_TTL
187        {
188            return (*objects, *bytes);
189        }
190
191        let measured = self.walk(self.root.join(ns.org()).join(ns.repo())).await;
192        cached.insert(key, (Instant::now(), measured.0, measured.1));
193
194        measured
195    }
196
197    // What the disk actually holds, which is not the sum of what the
198    // repositories logically hold: an object shared by three projects is three
199    // links to one set of bytes. Counting per-repository paths would report the
200    // pre-deduplication total and grow every time another project links the
201    // same pack — the opposite of what the number is for.
202    async fn measure(&self) -> (u64, u64) {
203        #[cfg(unix)]
204        {
205            self.walk_unique(self.root.clone()).await
206        }
207        #[cfg(not(unix))]
208        {
209            let (shared_objects, shared_bytes) = self.walk(self.root.join(".content")).await;
210            let (loose_objects, loose_bytes) = self.walk_unshared(self.root.clone()).await;
211
212            (shared_objects + loose_objects, shared_bytes + loose_bytes)
213        }
214    }
215
216    // Every hard link to one object reports the same inode, so counting each
217    // inode once measures bytes on disk exactly — including the copy fallback,
218    // which really does duplicate them and really should be counted twice.
219    #[cfg(unix)]
220    async fn walk_unique(&self, from: PathBuf) -> (u64, u64) {
221        use std::collections::HashSet;
222        use std::os::unix::fs::MetadataExt;
223
224        self.scan(from, |metadata, seen: &mut HashSet<(u64, u64)>| {
225            seen.insert((metadata.dev(), metadata.ino()))
226        })
227        .await
228    }
229
230    // Without inode numbers, count the shared store plus anything a repository
231    // holds that has no counterpart there. A copy made by the fallback path is
232    // undercounted, which needs a filesystem with no hard links to happen at all.
233    #[cfg(not(unix))]
234    async fn walk_unshared(&self, from: PathBuf) -> (u64, u64) {
235        let mut objects = 0;
236        let mut bytes = 0;
237        let mut directories = vec![from];
238
239        while let Some(directory) = directories.pop() {
240            let Ok(mut entries) = fs::read_dir(&directory).await else {
241                continue;
242            };
243
244            while let Ok(Some(entry)) = entries.next_entry().await {
245                let name = entry.file_name().to_string_lossy().into_owned();
246                // Only the root carries dot-directories worth skipping: .content
247                // is counted through the links that point into it, and .locks
248                // holds no objects at all.
249                if name.starts_with('.') && directory == self.root {
250                    continue;
251                }
252
253                match entry.metadata().await {
254                    Ok(metadata) if metadata.is_dir() => directories.push(entry.path()),
255                    Ok(metadata)
256                        if LocalStore::validate_oid(&name).is_ok()
257                            && fs::metadata(self.content_path(&name)).await.is_err() =>
258                    {
259                        objects += 1;
260                        bytes += metadata.len();
261                    }
262                    _ => {}
263                }
264            }
265        }
266
267        (objects, bytes)
268    }
269
270    async fn walk(&self, from: PathBuf) -> (u64, u64) {
271        self.scan(from, |_, _: &mut ()| true).await
272    }
273
274    async fn scan<S: Default>(
275        &self,
276        from: PathBuf,
277        mut counts: impl FnMut(&std::fs::Metadata, &mut S) -> bool,
278    ) -> (u64, u64) {
279        self.scans.fetch_add(1, Ordering::Relaxed);
280
281        let mut objects = 0;
282        let mut bytes = 0;
283        let mut state = S::default();
284
285        let mut directories = vec![from];
286        while let Some(directory) = directories.pop() {
287            let Ok(mut entries) = fs::read_dir(&directory).await else {
288                continue;
289            };
290
291            while let Ok(Some(entry)) = entries.next_entry().await {
292                let name = entry.file_name().to_string_lossy().into_owned();
293                if name.starts_with('.') {
294                    continue;
295                }
296
297                match entry.metadata().await {
298                    Ok(metadata) if metadata.is_dir() => directories.push(entry.path()),
299                    Ok(metadata)
300                        if LocalStore::validate_oid(&name).is_ok()
301                            && counts(&metadata, &mut state) =>
302                    {
303                        objects += 1;
304                        bytes += metadata.len();
305                    }
306                    _ => {}
307                }
308            }
309        }
310
311        (objects, bytes)
312    }
313}