Skip to main content

lfsx_server/storage/
rewrite.rs

1use std::path::Path;
2
3use serde::Serialize;
4use sha2::{Digest, Sha256};
5use tokio::fs;
6use tokio::io::AsyncReadExt;
7
8use super::codec;
9use super::{LocalStore, shares_bytes_with};
10use crate::error::Error;
11use crate::namespace::Namespace;
12
13#[derive(Debug, Default, Serialize, PartialEq, Eq)]
14pub struct CompressReport {
15    pub inspected: u64,
16    pub compressed: u64,
17    pub already: u64,
18    pub left_alone: u64,
19    pub refused: u64,
20    pub before: u64,
21    pub after: u64,
22    pub dry_run: bool,
23}
24
25impl LocalStore {
26    // Turning compression on only changes what arrives next. This is how a store
27    // that predates it stops paying full price for what it already holds.
28    pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
29        let level = self.compression.ok_or(Error::CompressionDisabled)?;
30
31        let mut report = CompressReport {
32            dry_run,
33            ..CompressReport::default()
34        };
35
36        let Ok(mut prefixes) = fs::read_dir(self.root.join(ns.org()).join(ns.repo())).await else {
37            return Ok(report);
38        };
39
40        while let Some(prefix) = prefixes.next_entry().await? {
41            let Ok(mut fanouts) = fs::read_dir(prefix.path()).await else {
42                continue;
43            };
44
45            while let Some(fanout) = fanouts.next_entry().await? {
46                self.compress_directory(&fanout.path(), level, &mut report)
47                    .await?;
48            }
49        }
50
51        if !dry_run && report.compressed > 0 {
52            self.forget(ns).await;
53        }
54
55        Ok(report)
56    }
57
58    async fn compress_directory(
59        &self,
60        directory: &Path,
61        level: i32,
62        report: &mut CompressReport,
63    ) -> Result<(), Error> {
64        let Ok(mut entries) = fs::read_dir(directory).await else {
65            return Ok(());
66        };
67
68        while let Some(entry) = entries.next_entry().await? {
69            let oid = entry.file_name().to_string_lossy().into_owned();
70            if Self::validate_oid(&oid).is_err() {
71                continue;
72            }
73
74            report.inspected += 1;
75            let path = entry.path();
76            let on_disk = entry.metadata().await?.len();
77            report.before += on_disk;
78
79            if self.is_framed(&path, on_disk).await? {
80                report.already += 1;
81                report.after += on_disk;
82                continue;
83            }
84
85            report.after += self
86                .compress_object(&path, &oid, on_disk, level, report)
87                .await?;
88        }
89
90        Ok(())
91    }
92
93    async fn is_framed(&self, path: &Path, on_disk: u64) -> Result<bool, Error> {
94        let file = fs::File::open(path).await?;
95
96        Ok(codec::Framed::open(file, on_disk).await?.is_some())
97    }
98
99    async fn compress_object(
100        &self,
101        path: &Path,
102        oid: &str,
103        on_disk: u64,
104        level: i32,
105        report: &mut CompressReport,
106    ) -> Result<u64, Error> {
107        let parent = path.parent().expect("objects live in a fanout directory");
108        let staged = self.staging_path(parent, oid);
109
110        let (digest, compressed) = match self.rewrite(path, &staged, level).await {
111            Ok(measured) => measured,
112            Err(error) => {
113                let _ = fs::remove_file(&staged).await;
114                return Err(error);
115            }
116        };
117
118        // The name is the digest, so this is the same check an operator would
119        // run by hand — and the last chance to run it, since afterwards the file
120        // is no longer the bytes it is named after.
121        if digest != oid {
122            tracing::warn!(oid, %digest, "object does not hash to its own name, leaving it alone");
123            let _ = fs::remove_file(&staged).await;
124            report.refused += 1;
125            return Ok(on_disk);
126        }
127
128        // An object that will not compress costs a header and an index to store
129        // this way. Leaving it is not a failure, it is the right answer.
130        if compressed >= on_disk || report.dry_run {
131            let _ = fs::remove_file(&staged).await;
132            if compressed >= on_disk {
133                report.left_alone += 1;
134                return Ok(on_disk);
135            }
136
137            report.compressed += 1;
138            return Ok(compressed);
139        }
140
141        self.swap_in(path, &staged, oid).await?;
142        report.compressed += 1;
143
144        Ok(compressed)
145    }
146
147    // Every repository holding these bytes has a link to one file, so replacing
148    // this repository's link with a compressed copy would break the sharing the
149    // deduplication just built. The shared copy is what gets replaced, and this
150    // repository is relinked to it. Repositories that have not run this yet keep
151    // the old bytes alive through their own links until their turn.
152    async fn swap_in(&self, path: &Path, staged: &Path, oid: &str) -> Result<(), Error> {
153        let content = self.content_path(oid);
154
155        if !shares_bytes_with(path, &content).await {
156            return Ok(fs::rename(staged, path).await?);
157        }
158
159        fs::rename(staged, &content).await?;
160
161        let parent = path.parent().expect("objects live in a fanout directory");
162        let relink = self.staging_path(parent, oid);
163        self.link(&content, &relink).await?;
164
165        Ok(fs::rename(&relink, path).await?)
166    }
167
168    async fn rewrite(
169        &self,
170        path: &Path,
171        staged: &Path,
172        level: i32,
173    ) -> Result<(String, u64), Error> {
174        let mut source = fs::File::open(path).await?;
175        let mut writer = codec::Writer::open(fs::File::create(staged).await?, level).await?;
176        let mut hasher = Sha256::new();
177        let mut buffer = vec![0u8; 1024 * 1024];
178
179        loop {
180            let read = source.read(&mut buffer).await?;
181            if read == 0 {
182                break;
183            }
184
185            hasher.update(&buffer[..read]);
186            writer.push(&buffer[..read]).await?;
187        }
188
189        writer.finish().await?;
190
191        Ok((
192            hex::encode(hasher.finalize()),
193            fs::metadata(staged).await?.len(),
194        ))
195    }
196}
197
198#[cfg(test)]
199mod tests;