Skip to main content

btrfs_cli/filesystem/
defrag.rs

1use crate::{Format, Runnable};
2use anyhow::{Context, Result};
3use btrfs_uapi::defrag::{
4    CompressSpec, CompressType, DefragRangeArgs, defrag_range,
5};
6use clap::Parser;
7use std::{
8    fs::{self, File},
9    os::unix::io::AsFd,
10    path::PathBuf,
11};
12
13const HEADING_COMPRESSION: &str = "Compression";
14const HEADING_RANGE: &str = "Range";
15
16/// Defragment files or directories on a btrfs filesystem
17#[derive(Parser, Debug)]
18pub struct FilesystemDefragCommand {
19    /// Defragment files in subdirectories recursively
20    #[clap(long, short)]
21    pub recursive: bool,
22
23    /// Flush data to disk immediately after defragmentation
24    #[clap(long, short)]
25    pub flush: bool,
26
27    /// Compress the file while defragmenting (optionally specify type: zlib, lzo, zstd)
28    #[clap(long, short, conflicts_with = "nocomp", help_heading = HEADING_COMPRESSION)]
29    pub compress: Option<Option<CompressType>>,
30
31    /// Compression level (used together with --compress)
32    #[clap(long = "level", short = 'L', requires = "compress", help_heading = HEADING_COMPRESSION)]
33    pub compress_level: Option<i8>,
34
35    /// Disable compression during defragmentation
36    #[clap(long, conflicts_with = "compress", help_heading = HEADING_COMPRESSION)]
37    pub nocomp: bool,
38
39    /// Defragment only bytes starting at this offset
40    #[clap(long, short, help_heading = HEADING_RANGE)]
41    pub start: Option<u64>,
42
43    /// Defragment only this many bytes
44    #[clap(long, help_heading = HEADING_RANGE)]
45    pub len: Option<u64>,
46
47    /// Target extent size threshold in bytes; extents larger than this are
48    /// considered already defragmented
49    #[clap(long, short, help_heading = HEADING_RANGE)]
50    pub target: Option<u64>,
51
52    /// Process the file in steps of this size rather than all at once
53    #[clap(long, help_heading = HEADING_RANGE)]
54    pub step: Option<u64>,
55
56    /// One or more files or directories to defragment
57    #[clap(required = true)]
58    pub paths: Vec<PathBuf>,
59}
60
61impl Runnable for FilesystemDefragCommand {
62    fn run(&self, _format: Format, _dry_run: bool) -> Result<()> {
63        let compress = self.compress.as_ref().map(|ct| CompressSpec {
64            compress_type: ct.unwrap_or(CompressType::Zlib),
65            level: self.compress_level,
66        });
67
68        let mut args = DefragRangeArgs::new();
69        if let Some(start) = self.start {
70            args = args.start(start);
71        }
72        if let Some(len) = self.len {
73            args = args.len(len);
74        }
75        if let Some(thresh) = self.target {
76            #[allow(clippy::cast_possible_truncation)]
77            // user-provided threshold fits u32
78            {
79                args = args.extent_thresh(thresh as u32);
80            }
81        }
82        if self.flush {
83            args = args.flush();
84        }
85        if self.nocomp {
86            args = args.nocomp();
87        } else if let Some(spec) = compress {
88            args = args.compress(spec);
89        }
90
91        let mut errors = 0u64;
92
93        for path in &self.paths {
94            let meta = fs::symlink_metadata(path).with_context(|| {
95                format!("cannot access '{}'", path.display())
96            })?;
97
98            if self.recursive && meta.is_dir() {
99                errors += self.defrag_recursive(path, &args)?;
100            } else if let Err(e) = self.defrag_one(path, &args) {
101                eprintln!("error: {e:#}");
102                errors += 1;
103            }
104        }
105
106        if errors > 0 {
107            anyhow::bail!("{errors} error(s) during defragmentation");
108        }
109
110        Ok(())
111    }
112}
113
114impl FilesystemDefragCommand {
115    /// Defragment a single file.
116    fn defrag_one(
117        &self,
118        path: &std::path::Path,
119        args: &DefragRangeArgs,
120    ) -> Result<()> {
121        log::info!("{}", path.display());
122        let file = File::open(path)
123            .with_context(|| format!("failed to open '{}'", path.display()))?;
124
125        if let Some(step) = self.step {
126            self.defrag_in_steps(&file, path, args, step)?;
127        } else {
128            defrag_range(file.as_fd(), args).with_context(|| {
129                format!("defrag failed on '{}'", path.display())
130            })?;
131        }
132        Ok(())
133    }
134
135    /// Walk a directory tree and defragment every regular file.
136    ///
137    /// Does not follow symlinks and does not cross filesystem boundaries,
138    /// matching the C reference's `nftw(path, cb, 10, FTW_MOUNT | FTW_PHYS)`.
139    fn defrag_recursive(
140        &self,
141        dir: &std::path::Path,
142        args: &DefragRangeArgs,
143    ) -> Result<u64> {
144        use std::os::unix::fs::MetadataExt;
145
146        let dir_dev = fs::metadata(dir)
147            .with_context(|| format!("cannot stat '{}'", dir.display()))?
148            .dev();
149
150        let mut errors = 0u64;
151        let mut stack = vec![dir.to_path_buf()];
152
153        while let Some(current) = stack.pop() {
154            let entries = match fs::read_dir(&current) {
155                Ok(e) => e,
156                Err(e) => {
157                    eprintln!(
158                        "error: cannot read '{}': {e}",
159                        current.display()
160                    );
161                    errors += 1;
162                    continue;
163                }
164            };
165
166            for entry in entries {
167                let entry = match entry {
168                    Ok(e) => e,
169                    Err(e) => {
170                        eprintln!("error: directory entry read failed: {e}");
171                        errors += 1;
172                        continue;
173                    }
174                };
175
176                let path = entry.path();
177
178                // Use symlink_metadata to avoid following symlinks (FTW_PHYS).
179                let meta = match fs::symlink_metadata(&path) {
180                    Ok(m) => m,
181                    Err(e) => {
182                        eprintln!(
183                            "error: cannot stat '{}': {e}",
184                            path.display()
185                        );
186                        errors += 1;
187                        continue;
188                    }
189                };
190
191                if meta.is_dir() {
192                    // Don't cross filesystem boundaries (FTW_MOUNT).
193                    if meta.dev() == dir_dev {
194                        stack.push(path);
195                    }
196                } else if meta.is_file()
197                    && let Err(e) = self.defrag_one(&path, args)
198                {
199                    eprintln!("error: {e:#}");
200                    errors += 1;
201                }
202                // Skip symlinks, sockets, fifos, etc.
203            }
204        }
205
206        Ok(errors)
207    }
208
209    /// Process a file in fixed-size steps, flushing between each step.
210    ///
211    /// Matches `defrag_range_in_steps` from the C reference.
212    #[allow(clippy::unused_self)] // method kept on the command struct for consistency
213    fn defrag_in_steps(
214        &self,
215        file: &File,
216        path: &std::path::Path,
217        args: &DefragRangeArgs,
218        step: u64,
219    ) -> Result<()> {
220        use std::os::unix::fs::MetadataExt;
221
222        let file_size = file.metadata()?.size();
223        let mut offset = args.start;
224        let end = if args.len == u64::MAX {
225            u64::MAX
226        } else {
227            args.start.saturating_add(args.len)
228        };
229
230        while offset < end {
231            // Re-check file size each iteration in case it changed.
232            let current_size = file.metadata()?.size();
233            if offset >= current_size {
234                break;
235            }
236
237            let remaining = end.saturating_sub(offset).min(step);
238            let mut step_args = args.clone();
239            step_args.start = offset;
240            step_args.len = remaining;
241            // Always flush between steps.
242            step_args.flush = true;
243
244            defrag_range(file.as_fd(), &step_args).with_context(|| {
245                format!(
246                    "defrag failed on '{}' at offset {offset}",
247                    path.display()
248                )
249            })?;
250
251            offset = match offset.checked_add(step) {
252                Some(next) => next,
253                None => break, // overflow means we've covered the whole file
254            };
255        }
256
257        // If the file grew since we started, the original file_size might be
258        // less than the current size, but we only defrag through `end`.
259        let _ = file_size;
260
261        Ok(())
262    }
263}