Skip to main content

lfsx_server/storage/
dedupe.rs

1use std::path::Path;
2
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5use tokio::fs;
6use tokio::io::AsyncReadExt;
7
8use super::LocalStore;
9use crate::error::Error;
10use crate::namespace::Namespace;
11
12#[derive(Debug, Default, Serialize, PartialEq, Eq)]
13pub struct DedupeReport {
14    pub inspected: u64,
15    pub already_shared: u64,
16    pub adopted: u64,
17    pub linked: u64,
18    pub reclaimed: u64,
19    pub refused: u64,
20    pub dry_run: bool,
21}
22
23impl LocalStore {
24    // Objects written before the shared store existed are ordinary files with a
25    // single link. They serve correctly and they never collapse, so a server
26    // that predates deduplication keeps paying full price for every pack two
27    // projects share. This folds them in, one repository at a time.
28    pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
29        let mut report = DedupeReport {
30            dry_run,
31            ..DedupeReport::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.dedupe_directory(&fanout.path(), &mut report).await?;
45            }
46        }
47
48        if !dry_run && (report.adopted > 0 || report.linked > 0) {
49            self.forget(ns).await;
50        }
51
52        Ok(report)
53    }
54
55    async fn dedupe_directory(
56        &self,
57        directory: &Path,
58        report: &mut DedupeReport,
59    ) -> Result<(), Error> {
60        let Ok(mut entries) = fs::read_dir(directory).await else {
61            return Ok(());
62        };
63
64        while let Some(entry) = entries.next_entry().await? {
65            let oid = entry.file_name().to_string_lossy().into_owned();
66            if Self::validate_oid(&oid).is_err() {
67                continue;
68            }
69
70            report.inspected += 1;
71            let path = entry.path();
72            let content = self.content_path(&oid);
73
74            if shares_bytes_with(&path, &content).await {
75                report.already_shared += 1;
76                continue;
77            }
78
79            match fs::metadata(&content).await {
80                Ok(shared) => {
81                    self.adopt(&path, &content, &oid, shared.len(), report)
82                        .await?
83                }
84                Err(_) => self.promote(&path, &content, &oid, report).await?,
85            }
86        }
87
88        Ok(())
89    }
90
91    // The shared store already holds these bytes. Replacing this repository's
92    // copy with a link to them is what frees the disk — but only after checking
93    // that what is there really is this object: linking to a corrupt entry would
94    // spread it to a repository that had a good copy of its own.
95    async fn adopt(
96        &self,
97        path: &Path,
98        content: &Path,
99        oid: &str,
100        size: u64,
101        report: &mut DedupeReport,
102    ) -> Result<(), Error> {
103        if report.dry_run {
104            report.linked += 1;
105            report.reclaimed += size;
106            return Ok(());
107        }
108
109        if !hashes_to(content, oid).await {
110            tracing::warn!(
111                oid,
112                "shared copy does not hash to its own name, leaving the repository's own file alone"
113            );
114            report.refused += 1;
115            return Ok(());
116        }
117
118        let parent = path.parent().expect("objects live in a fanout directory");
119        let staged = self.staging_path(parent, oid);
120
121        self.link(content, &staged).await?;
122        // Rename over the original rather than removing it first: a crash here
123        // leaves either the old file or the new link, never a gap where the
124        // repository has no object at all.
125        fs::rename(&staged, path).await?;
126
127        report.linked += 1;
128        report.reclaimed += size;
129
130        Ok(())
131    }
132
133    // Nothing shares these bytes yet, so this repository's copy becomes the
134    // shared one and gets a link back in its place. Nothing is freed today; the
135    // next repository to hold the same object is the one that stops paying.
136    async fn promote(
137        &self,
138        path: &Path,
139        content: &Path,
140        oid: &str,
141        report: &mut DedupeReport,
142    ) -> Result<(), Error> {
143        if report.dry_run {
144            report.adopted += 1;
145            return Ok(());
146        }
147
148        if !hashes_to(path, oid).await {
149            tracing::warn!(
150                oid,
151                "object does not hash to its own name, leaving it out of the shared store"
152            );
153            report.refused += 1;
154            return Ok(());
155        }
156
157        let parent = content.parent().expect("content paths have a parent");
158        fs::create_dir_all(parent).await?;
159
160        fs::rename(path, content).await?;
161        if let Err(error) = self.link(content, path).await {
162            // Put it back where the repository expects it rather than leave the
163            // object reachable only from the shared store.
164            fs::rename(content, path).await?;
165            return Err(error.into());
166        }
167
168        report.adopted += 1;
169
170        Ok(())
171    }
172}
173
174// Two paths that resolve to the same inode are already one set of bytes with
175// two names, which is exactly what deduplication produces — so this is how a
176// second run knows there is nothing left to do.
177#[cfg(unix)]
178pub(super) async fn shares_bytes_with(path: &Path, content: &Path) -> bool {
179    use std::os::unix::fs::MetadataExt;
180
181    let (Ok(one), Ok(other)) = (fs::metadata(path).await, fs::metadata(content).await) else {
182        return false;
183    };
184
185    (one.dev(), one.ino()) == (other.dev(), other.ino())
186}
187
188// Without inode numbers there is no way to tell a link from a copy, so every
189// run relinks. The result is the same, the work is repeated. The server ships
190// on Linux; this keeps the tests honest everywhere else.
191#[cfg(not(unix))]
192pub(super) async fn shares_bytes_with(_path: &Path, _content: &Path) -> bool {
193    false
194}
195
196async fn hashes_to(path: &Path, oid: &str) -> bool {
197    let Ok(mut file) = fs::File::open(path).await else {
198        return false;
199    };
200
201    let mut hasher = Sha256::new();
202    let mut buffer = vec![0u8; 128 * 1024];
203
204    loop {
205        match file.read(&mut buffer).await {
206            Ok(0) => break,
207            Ok(read) => hasher.update(&buffer[..read]),
208            Err(_) => return false,
209        }
210    }
211
212    hex::encode(hasher.finalize()) == oid
213}
214
215#[cfg(test)]
216mod tests;