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 incomplete: bool,
19    pub dry_run: bool,
20}
21
22impl LocalStore {
23    // Objects go, the fanout directories they lived in stay. Removing an
24    // emptied directory raced every upload: a push creates its fanout, and for
25    // the moment between that and the staging file appearing the directory is
26    // empty, so a collection running alongside took it and the push failed on a
27    // directory that had just been made for it. Nothing here can hold a lock the
28    // filesystem would honour, so the fix is to stop competing — the shared
29    // .content tree has never pruned its directories either. What is left is an
30    // inode and a block per prefix, reused by the next object that hashes into
31    // it, against a push failing for a reason no operator could act on.
32    pub async fn sweep(
33        &self,
34        ns: &Namespace,
35        retained: &HashSet<String>,
36        grace: Duration,
37        dry_run: bool,
38    ) -> Result<SweepReport, Error> {
39        let walk = self.objects_of(ns).await;
40        let mut report = SweepReport {
41            dry_run,
42            incomplete: !walk.complete,
43            ..SweepReport::default()
44        };
45
46        // Resolved once. The set of repositories does not change while a sweep
47        // runs, and listing every organisation again for each object made
48        // collection cost objects times repositories: invisible on a small store,
49        // and the whole runtime on the one where an operator finally needs it.
50        let elsewhere = self.other_repositories(ns).await;
51
52        for found in walk.objects {
53            if retained.contains(&found.oid) {
54                continue;
55            }
56
57            let metadata = fs::metadata(&found.path).await?;
58            if age(&metadata) < grace {
59                report.within_grace += 1;
60                continue;
61            }
62
63            self.collect(
64                &found.path,
65                &found.oid,
66                metadata.len(),
67                &elsewhere,
68                &mut report,
69            )
70            .await?;
71        }
72
73        if !dry_run {
74            self.forget_capacity().await;
75        }
76
77        Ok(report)
78    }
79
80    // The bytes live once under .content, linked from each repository that holds
81    // them. Dropping this repository's link frees nothing until the last one
82    // goes, so only then is it counted as freed — and a dry run that counted
83    // otherwise would promise space it cannot deliver.
84    async fn collect(
85        &self,
86        path: &Path,
87        oid: &str,
88        held: u64,
89        elsewhere: &[PathBuf],
90        report: &mut SweepReport,
91    ) -> Result<(), Error> {
92        report.swept += 1;
93
94        if report.dry_run {
95            // This repository's link is still there, so it is one of the ones the
96            // count includes.
97            if !self.referenced_elsewhere(oid, elsewhere, 1).await {
98                report.bytes += held;
99            }
100
101            return Ok(());
102        }
103
104        fs::remove_file(path).await?;
105
106        if self.referenced_elsewhere(oid, elsewhere, 0).await {
107            return Ok(());
108        }
109
110        let content = self.content_path(oid);
111        let size = fs::metadata(&content)
112            .await
113            .map(|shared| shared.len())
114            .unwrap_or(held);
115
116        // Count the bytes only if this call is the one that removed them. Two
117        // repositories dropping their last reference at the same time would
118        // otherwise each claim the same space, and two reports would add up to
119        // more than the disk ever held.
120        if fs::remove_file(&content).await.is_ok() {
121            report.bytes += size;
122        }
123
124        Ok(())
125    }
126
127    // Is this object still linked from a repository other than the one being
128    // swept? `ours` is how many of the links belong to the repository being
129    // swept: one before its link is removed, none after.
130    //
131    // The filesystem already keeps this count, so on Unix it is one stat rather
132    // than a walk of the store: the shared entry under .content, plus one link
133    // per repository holding the object.
134    async fn referenced_elsewhere(&self, oid: &str, elsewhere: &[PathBuf], ours: u64) -> bool {
135        if let Some(links) = links_to(&self.content_path(oid)).await {
136            return links > 1 + ours;
137        }
138
139        // No shared entry to count, which is what an object written before the
140        // shared store looks like, or a platform without link counts. Probing the
141        // repositories resolved at the start of the sweep is what is left.
142        for repo in elsewhere {
143            let candidate = repo.join(&oid[0..2]).join(&oid[2..4]).join(oid);
144            if fs::metadata(candidate).await.is_ok() {
145                return true;
146            }
147        }
148
149        false
150    }
151
152    // Every repository in the store except the one being swept, as directories.
153    async fn other_repositories(&self, sweeping: &Namespace) -> Vec<PathBuf> {
154        let mut out = Vec::new();
155        let Ok(mut orgs) = fs::read_dir(&self.root).await else {
156            return out;
157        };
158
159        while let Ok(Some(org)) = orgs.next_entry().await {
160            let org_name = org.file_name().to_string_lossy().into_owned();
161            if org_name.starts_with('.') {
162                continue;
163            }
164
165            let Ok(mut repos) = fs::read_dir(org.path()).await else {
166                continue;
167            };
168
169            while let Ok(Some(repo)) = repos.next_entry().await {
170                let repo_name = repo.file_name().to_string_lossy().into_owned();
171                if org_name == sweeping.org() && repo_name == sweeping.repo() {
172                    continue;
173                }
174
175                out.push(repo.path());
176            }
177        }
178
179        out
180    }
181}
182
183// How many names the bytes have. None where the filesystem cannot say, which is
184// every non-Unix target and any object with no shared entry to count from.
185#[cfg(unix)]
186async fn links_to(content: &Path) -> Option<u64> {
187    use std::os::unix::fs::MetadataExt;
188
189    fs::metadata(content)
190        .await
191        .ok()
192        .map(|shared| shared.nlink())
193}
194
195#[cfg(not(unix))]
196async fn links_to(_content: &Path) -> Option<u64> {
197    None
198}
199
200pub(super) fn age(metadata: &std::fs::Metadata) -> Duration {
201    metadata
202        .modified()
203        .ok()
204        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
205        .unwrap_or_default()
206}
207
208const USAGE_TTL: Duration = Duration::from_secs(60);
209
210impl LocalStore {
211    pub async fn usage(&self) -> (u64, u64) {
212        let mut cached = self.usage.lock().await;
213
214        if let Some((measured_at, objects, bytes)) = *cached
215            && measured_at.elapsed() < USAGE_TTL
216        {
217            return (objects, bytes);
218        }
219
220        let measured = self.measure().await;
221        *cached = Some((Instant::now(), measured.0, measured.1));
222
223        measured
224    }
225
226    // Uncached on purpose: what a repository holds right now. Remembering it is
227    // the seam's job, because a bucket needs exactly the same policy and used
228    // to go without.
229    pub(super) async fn measure_of(&self, ns: &Namespace) -> (u64, u64) {
230        self.walk(self.root.join(ns.org()).join(ns.repo())).await
231    }
232
233    // The whole-store figure, because it just became wrong. Freeing gigabytes
234    // and then reporting the old total for another minute is how an operator
235    // concludes the collection — or the migration — did nothing. What the
236    // repository holds is remembered a layer up and dropped there.
237    pub(super) async fn forget_capacity(&self) {
238        *self.usage.lock().await = None;
239    }
240
241    // What the disk actually holds, which is not the sum of what the
242    // repositories logically hold: an object shared by three projects is three
243    // links to one set of bytes. Counting per-repository paths would report the
244    // pre-deduplication total and grow every time another project links the
245    // same pack — the opposite of what the number is for.
246    async fn measure(&self) -> (u64, u64) {
247        #[cfg(unix)]
248        {
249            self.walk_unique(self.root.clone()).await
250        }
251        #[cfg(not(unix))]
252        {
253            let (shared_objects, shared_bytes) = self.walk(self.root.join(".content")).await;
254            let (loose_objects, loose_bytes) = self.walk_unshared(self.root.clone()).await;
255
256            (shared_objects + loose_objects, shared_bytes + loose_bytes)
257        }
258    }
259
260    // Every hard link to one object reports the same inode, so counting each
261    // inode once measures bytes on disk exactly — including the copy fallback,
262    // which really does duplicate them and really should be counted twice.
263    #[cfg(unix)]
264    async fn walk_unique(&self, from: PathBuf) -> (u64, u64) {
265        use std::collections::HashSet;
266        use std::os::unix::fs::MetadataExt;
267
268        self.scan(from, |metadata, seen: &mut HashSet<(u64, u64)>| {
269            seen.insert((metadata.dev(), metadata.ino()))
270        })
271        .await
272    }
273
274    // Without inode numbers, count the shared store plus anything a repository
275    // holds that has no counterpart there. A copy made by the fallback path is
276    // undercounted, which needs a filesystem with no hard links to happen at all.
277    #[cfg(not(unix))]
278    async fn walk_unshared(&self, from: PathBuf) -> (u64, u64) {
279        let mut objects = 0;
280        let mut bytes = 0;
281        let mut directories = vec![from];
282
283        while let Some(directory) = directories.pop() {
284            let Ok(mut entries) = fs::read_dir(&directory).await else {
285                continue;
286            };
287
288            while let Ok(Some(entry)) = entries.next_entry().await {
289                let name = entry.file_name().to_string_lossy().into_owned();
290                // Only the root carries dot-directories worth skipping: .content
291                // is counted through the links that point into it, and .locks
292                // holds no objects at all.
293                if name.starts_with('.') && directory == self.root {
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                            && fs::metadata(self.content_path(&name)).await.is_err() =>
302                    {
303                        objects += 1;
304                        bytes += metadata.len();
305                    }
306                    _ => {}
307                }
308            }
309        }
310
311        (objects, bytes)
312    }
313
314    async fn walk(&self, from: PathBuf) -> (u64, u64) {
315        self.scan(from, |_, _: &mut ()| true).await
316    }
317
318    async fn scan<S: Default>(
319        &self,
320        from: PathBuf,
321        mut counts: impl FnMut(&std::fs::Metadata, &mut S) -> bool,
322    ) -> (u64, u64) {
323        self.scans.fetch_add(1, Ordering::Relaxed);
324
325        let mut objects = 0;
326        let mut bytes = 0;
327        let mut state = S::default();
328
329        let mut directories = vec![from];
330        while let Some(directory) = directories.pop() {
331            let Ok(mut entries) = fs::read_dir(&directory).await else {
332                continue;
333            };
334
335            while let Ok(Some(entry)) = entries.next_entry().await {
336                let name = entry.file_name().to_string_lossy().into_owned();
337                if name.starts_with('.') {
338                    continue;
339                }
340
341                match entry.metadata().await {
342                    Ok(metadata) if metadata.is_dir() => directories.push(entry.path()),
343                    Ok(metadata)
344                        if LocalStore::validate_oid(&name).is_ok()
345                            && counts(&metadata, &mut state) =>
346                    {
347                        objects += 1;
348                        bytes += metadata.len();
349                    }
350                    _ => {}
351                }
352            }
353        }
354
355        (objects, bytes)
356    }
357}