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