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