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