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(ns).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    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
227        let key = ns.to_string();
228        let mut cached = self.per_namespace.lock().await;
229
230        if let Some((measured_at, objects, bytes)) = cached.get(&key)
231            && measured_at.elapsed() < USAGE_TTL
232        {
233            return (*objects, *bytes);
234        }
235
236        let measured = self.walk(self.root.join(ns.org()).join(ns.repo())).await;
237        cached.insert(key, (Instant::now(), measured.0, measured.1));
238
239        measured
240    }
241
242    // A quota is checked on every negotiation, so the figure behind it can
243    // afford neither a walk each time nor a minute of staleness: stale in one
244    // direction lets a repository push past its budget, and in the other it
245    // refuses space the client has just freed. A stored object adds to the
246    // cached figure, and a collection drops it so the next reader measures what
247    // is really left rather than trusting arithmetic across hard links.
248    pub async fn stored(&self, ns: &Namespace, bytes: u64) {
249        if let Some((_, objects, held)) = self.per_namespace.lock().await.get_mut(&ns.to_string()) {
250            *objects += 1;
251            *held += bytes;
252        }
253    }
254
255    // Both figures, because both just became wrong. Freeing gigabytes and then
256    // reporting the old total for another minute is how an operator concludes
257    // the collection — or the migration — did nothing.
258    pub async fn forget(&self, ns: &Namespace) {
259        self.per_namespace.lock().await.remove(&ns.to_string());
260        *self.usage.lock().await = None;
261    }
262
263    // What the disk actually holds, which is not the sum of what the
264    // repositories logically hold: an object shared by three projects is three
265    // links to one set of bytes. Counting per-repository paths would report the
266    // pre-deduplication total and grow every time another project links the
267    // same pack — the opposite of what the number is for.
268    async fn measure(&self) -> (u64, u64) {
269        #[cfg(unix)]
270        {
271            self.walk_unique(self.root.clone()).await
272        }
273        #[cfg(not(unix))]
274        {
275            let (shared_objects, shared_bytes) = self.walk(self.root.join(".content")).await;
276            let (loose_objects, loose_bytes) = self.walk_unshared(self.root.clone()).await;
277
278            (shared_objects + loose_objects, shared_bytes + loose_bytes)
279        }
280    }
281
282    // Every hard link to one object reports the same inode, so counting each
283    // inode once measures bytes on disk exactly — including the copy fallback,
284    // which really does duplicate them and really should be counted twice.
285    #[cfg(unix)]
286    async fn walk_unique(&self, from: PathBuf) -> (u64, u64) {
287        use std::collections::HashSet;
288        use std::os::unix::fs::MetadataExt;
289
290        self.scan(from, |metadata, seen: &mut HashSet<(u64, u64)>| {
291            seen.insert((metadata.dev(), metadata.ino()))
292        })
293        .await
294    }
295
296    // Without inode numbers, count the shared store plus anything a repository
297    // holds that has no counterpart there. A copy made by the fallback path is
298    // undercounted, which needs a filesystem with no hard links to happen at all.
299    #[cfg(not(unix))]
300    async fn walk_unshared(&self, from: PathBuf) -> (u64, u64) {
301        let mut objects = 0;
302        let mut bytes = 0;
303        let mut directories = vec![from];
304
305        while let Some(directory) = directories.pop() {
306            let Ok(mut entries) = fs::read_dir(&directory).await else {
307                continue;
308            };
309
310            while let Ok(Some(entry)) = entries.next_entry().await {
311                let name = entry.file_name().to_string_lossy().into_owned();
312                // Only the root carries dot-directories worth skipping: .content
313                // is counted through the links that point into it, and .locks
314                // holds no objects at all.
315                if name.starts_with('.') && directory == self.root {
316                    continue;
317                }
318
319                match entry.metadata().await {
320                    Ok(metadata) if metadata.is_dir() => directories.push(entry.path()),
321                    Ok(metadata)
322                        if LocalStore::validate_oid(&name).is_ok()
323                            && fs::metadata(self.content_path(&name)).await.is_err() =>
324                    {
325                        objects += 1;
326                        bytes += metadata.len();
327                    }
328                    _ => {}
329                }
330            }
331        }
332
333        (objects, bytes)
334    }
335
336    async fn walk(&self, from: PathBuf) -> (u64, u64) {
337        self.scan(from, |_, _: &mut ()| true).await
338    }
339
340    async fn scan<S: Default>(
341        &self,
342        from: PathBuf,
343        mut counts: impl FnMut(&std::fs::Metadata, &mut S) -> bool,
344    ) -> (u64, u64) {
345        self.scans.fetch_add(1, Ordering::Relaxed);
346
347        let mut objects = 0;
348        let mut bytes = 0;
349        let mut state = S::default();
350
351        let mut directories = vec![from];
352        while let Some(directory) = directories.pop() {
353            let Ok(mut entries) = fs::read_dir(&directory).await else {
354                continue;
355            };
356
357            while let Ok(Some(entry)) = entries.next_entry().await {
358                let name = entry.file_name().to_string_lossy().into_owned();
359                if name.starts_with('.') {
360                    continue;
361                }
362
363                match entry.metadata().await {
364                    Ok(metadata) if metadata.is_dir() => directories.push(entry.path()),
365                    Ok(metadata)
366                        if LocalStore::validate_oid(&name).is_ok()
367                            && counts(&metadata, &mut state) =>
368                    {
369                        objects += 1;
370                        bytes += metadata.len();
371                    }
372                    _ => {}
373                }
374            }
375        }
376
377        (objects, bytes)
378    }
379}