Skip to main content

btrfs_cli/rescue/
clear_space_cache.rs

1use crate::{RunContext, Runnable, util::is_mounted};
2use anyhow::{Context, Result, bail};
3use btrfs_disk::{
4    items::{FileExtentBody, FileExtentItem},
5    raw,
6    tree::{DiskKey, KeyType},
7};
8use btrfs_transaction::{
9    allocation,
10    filesystem::Filesystem,
11    items,
12    path::BtrfsPath,
13    search::{self, SearchIntent},
14    transaction::Transaction,
15};
16use clap::Parser;
17use std::{
18    fs::OpenOptions,
19    io::{Read, Seek, Write},
20    path::PathBuf,
21};
22
23/// Free space tree object ID (tree 10).
24const FREE_SPACE_TREE_OBJECTID: u64 =
25    raw::BTRFS_FREE_SPACE_TREE_OBJECTID as u64;
26
27/// Root tree ID.
28const ROOT_TREE_OBJECTID: u64 = 1;
29
30/// Special objectid that holds v1 free space cache headers
31/// (`BTRFS_FREE_SPACE_OBJECTID` == -11 sign-extended).
32const FREE_SPACE_OBJECTID: u64 =
33    (raw::BTRFS_FREE_SPACE_OBJECTID as i64).cast_unsigned();
34
35/// The v1 free space header item is stored under key type 0 (no
36/// dedicated `KeyType` variant; this matches the kernel and
37/// btrfs-progs).
38const FREE_SPACE_HEADER_KEY_TYPE: u8 = 0;
39
40/// Free space cache version to clear.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
42pub enum SpaceCacheVersion {
43    V1,
44    V2,
45}
46
47/// Completely remove the v1 or v2 free space cache
48///
49/// For v2, drops the FREE_SPACE_TREE root and clears the
50/// FREE_SPACE_TREE and FREE_SPACE_TREE_VALID compat_ro flags so that
51/// the kernel rebuilds the tree on the next mount with `space_cache=v2`.
52///
53/// For v1, see Stage G in transaction/PLAN.md: clearing v1 requires
54/// freeing data extents owned by the hidden free-space-cache inodes,
55/// which the transaction crate does not yet support.
56///
57/// The device must not be mounted.
58#[derive(Parser, Debug)]
59pub struct RescueClearSpaceCacheCommand {
60    /// Free space cache version to remove
61    version: SpaceCacheVersion,
62
63    /// Path to the btrfs device
64    device: PathBuf,
65}
66
67/// Recursively walk a tree starting at `bytenr`, collecting every block
68/// address (root, internal nodes, leaves).
69fn collect_tree_blocks<R: Read + Write + Seek>(
70    fs: &mut Filesystem<R>,
71    bytenr: u64,
72    out: &mut Vec<(u64, u8)>,
73) -> Result<()> {
74    let eb = fs
75        .read_block(bytenr)
76        .with_context(|| format!("failed to read tree block at {bytenr}"))?;
77    let level = eb.level();
78    out.push((bytenr, level));
79
80    if eb.is_node() {
81        let nritems = eb.nritems() as usize;
82        for slot in 0..nritems {
83            let child = eb.key_ptr_blockptr(slot);
84            collect_tree_blocks(fs, child, out)?;
85        }
86    }
87    Ok(())
88}
89
90/// Look up the `FREE_SPACE_HEADER` for one block group and, if
91/// present, walk the cache inode's `EXTENT_DATA` items to collect a
92/// [`V1CacheEntry`].
93fn read_v1_cache_entry<R: Read + Write + Seek>(
94    fs: &mut Filesystem<R>,
95    bg_start: u64,
96) -> Result<Option<V1CacheEntry>> {
97    // Step 1: find the FREE_SPACE_HEADER and parse the embedded
98    // location disk_key to get the inode number.
99    let header_key = DiskKey {
100        objectid: FREE_SPACE_OBJECTID,
101        key_type: KeyType::from_raw(FREE_SPACE_HEADER_KEY_TYPE),
102        offset: bg_start,
103    };
104    let mut path = BtrfsPath::new();
105    let found = search::search_slot(
106        None,
107        fs,
108        ROOT_TREE_OBJECTID,
109        &header_key,
110        &mut path,
111        SearchIntent::ReadOnly,
112        false,
113    )
114    .context("failed to search root tree for v1 cache header")?;
115    if !found {
116        path.release();
117        return Ok(None);
118    }
119    let leaf = path.nodes[0]
120        .as_ref()
121        .ok_or_else(|| anyhow::anyhow!("no leaf in v1 header path"))?;
122    let payload = leaf.item_data(path.slots[0]);
123    if payload.len() < 17 {
124        path.release();
125        bail!("FREE_SPACE_HEADER for bg {bg_start} truncated");
126    }
127    // The header begins with a btrfs_disk_key (17 bytes):
128    //   u64 objectid | u8 type | u64 offset
129    let ino = u64::from_le_bytes(payload[0..8].try_into().unwrap());
130    path.release();
131
132    // Step 2: walk the cache inode's EXTENT_DATA items in the root
133    // tree, recording (file_offset, disk_bytenr, disk_num_bytes) for
134    // each non-inline regular extent.
135    let mut extents: Vec<V1Extent> = Vec::new();
136
137    let start = DiskKey {
138        objectid: ino,
139        key_type: KeyType::ExtentData,
140        offset: 0,
141    };
142    let mut path = BtrfsPath::new();
143    search::search_slot(
144        None,
145        fs,
146        ROOT_TREE_OBJECTID,
147        &start,
148        &mut path,
149        SearchIntent::ReadOnly,
150        false,
151    )
152    .context("failed to search root tree for v1 cache extents")?;
153
154    'walk: while let Some(leaf) = path.nodes[0].as_ref() {
155        let nritems = leaf.nritems() as usize;
156        if path.slots[0] >= nritems {
157            if !search::next_leaf(fs, &mut path).context("next_leaf failed")? {
158                break;
159            }
160            continue;
161        }
162        let key = leaf.item_key(path.slots[0]);
163        if key.objectid != ino {
164            break 'walk;
165        }
166        if key.key_type == KeyType::ExtentData {
167            let data = leaf.item_data(path.slots[0]);
168            let fei = FileExtentItem::parse(data).ok_or_else(|| {
169                anyhow::anyhow!(
170                    "malformed FILE_EXTENT for v1 cache inode {ino} offset {}",
171                    key.offset
172                )
173            })?;
174            match fei.body {
175                FileExtentBody::Regular {
176                    disk_bytenr,
177                    disk_num_bytes,
178                    ..
179                } => {
180                    extents.push(V1Extent {
181                        file_offset: key.offset,
182                        disk_bytenr,
183                        disk_num_bytes,
184                    });
185                }
186                FileExtentBody::Inline { .. } => {
187                    // Inline extents have no separate data extent;
188                    // record with disk_bytenr=0 so the apply pass
189                    // still deletes the EXTENT_DATA item but skips
190                    // the data ref drop.
191                    extents.push(V1Extent {
192                        file_offset: key.offset,
193                        disk_bytenr: 0,
194                        disk_num_bytes: 0,
195                    });
196                }
197            }
198        }
199        path.slots[0] += 1;
200    }
201    path.release();
202
203    Ok(Some(V1CacheEntry { ino, extents }))
204}
205
206/// Delete a single item identified by an exact key. Returns `false`
207/// (without erroring) if the item is missing, matching the C
208/// reference's tolerant behaviour for the cache inode item.
209fn delete_one_item<R: Read + Write + Seek>(
210    trans: &mut Transaction<R>,
211    fs: &mut Filesystem<R>,
212    tree_id: u64,
213    key: &DiskKey,
214) -> Result<bool> {
215    let mut path = BtrfsPath::new();
216    let found = search::search_slot(
217        Some(trans),
218        fs,
219        tree_id,
220        key,
221        &mut path,
222        SearchIntent::Delete,
223        true,
224    )
225    .with_context(|| {
226        format!("failed to search for {key:?} in tree {tree_id}")
227    })?;
228    if !found {
229        path.release();
230        return Ok(false);
231    }
232    let leaf = path.nodes[0]
233        .as_mut()
234        .ok_or_else(|| anyhow::anyhow!("delete_one_item: no leaf in path"))?;
235    items::del_items(leaf, path.slots[0], 1);
236    fs.mark_dirty(leaf);
237    path.release();
238    Ok(true)
239}
240
241impl Runnable for RescueClearSpaceCacheCommand {
242    fn run(&self, _ctx: &RunContext) -> Result<()> {
243        if is_mounted(&self.device) {
244            bail!("{} is currently mounted", self.device.display());
245        }
246
247        match self.version {
248            SpaceCacheVersion::V1 => self.clear_v1(),
249            SpaceCacheVersion::V2 => self.clear_v2(),
250        }
251    }
252}
253
254/// One free space cache file referenced from a block group's
255/// `FREE_SPACE_HEADER`. Collected during the read pass and consumed
256/// (via fresh COW searches) during the apply pass.
257struct V1CacheEntry {
258    /// Inode number that holds the cache file in the root tree.
259    ino: u64,
260    /// File-extent records found under that inode.
261    extents: Vec<V1Extent>,
262}
263
264struct V1Extent {
265    /// File offset key for this `EXTENT_DATA` item.
266    file_offset: u64,
267    /// Disk bytenr of the referenced extent (0 = hole/inline, skip).
268    disk_bytenr: u64,
269    /// On-disk byte length of the referenced extent.
270    disk_num_bytes: u64,
271}
272
273impl RescueClearSpaceCacheCommand {
274    /// Clear the v1 free space cache: for every block group, find its
275    /// `FREE_SPACE_HEADER` in the root tree, free the data extents
276    /// owned by the cache inode, and delete the cache items
277    /// (`FREE_SPACE_HEADER`, `EXTENT_DATA`s, `INODE_ITEM`).
278    ///
279    /// Mirrors the algorithm in `btrfs_clear_free_space_cache` from
280    /// btrfs-progs `kernel-shared/free-space-cache.c`, except that
281    /// the entire run happens in a single transaction (the C code
282    /// commits in clusters of 16 block groups for very large
283    /// filesystems).
284    fn clear_v1(&self) -> Result<()> {
285        let file = OpenOptions::new()
286            .read(true)
287            .write(true)
288            .open(&self.device)
289            .with_context(|| {
290                format!("failed to open '{}'", self.device.display())
291            })?;
292
293        let mut fs = Filesystem::open(file).with_context(|| {
294            format!("failed to open filesystem on '{}'", self.device.display())
295        })?;
296
297        // Pass 1: scan every block group for a v1 cache header and
298        // collect the work list before mutating anything.
299        let block_groups = allocation::load_block_groups(&mut fs)
300            .context("failed to load block groups")?;
301
302        let mut entries: Vec<(u64, V1CacheEntry)> = Vec::new();
303        for bg in &block_groups {
304            if let Some(entry) = read_v1_cache_entry(&mut fs, bg.start)? {
305                entries.push((bg.start, entry));
306            }
307        }
308
309        if entries.is_empty() {
310            // Still bump cache_generation so the kernel knows the v1
311            // cache is invalidated even on filesystems where it was
312            // never written.
313            if fs.superblock.cache_generation != u64::MAX {
314                let trans = Transaction::start(&mut fs)
315                    .context("failed to start transaction")?;
316                fs.superblock.cache_generation = u64::MAX;
317                trans
318                    .commit(&mut fs)
319                    .context("failed to commit transaction")?;
320                fs.sync().context("failed to sync to disk")?;
321            }
322            println!(
323                "no v1 free space cache found on {}",
324                self.device.display()
325            );
326            return Ok(());
327        }
328
329        // Pass 2: apply.
330        let mut trans = Transaction::start(&mut fs)
331            .context("failed to start transaction")?;
332
333        let mut total_extents_freed: usize = 0;
334        for (bg_start, entry) in &entries {
335            // Drop data refs first so the EXTENT_ITEMs are reclaimed
336            // when the delayed refs flush.
337            for ext in &entry.extents {
338                if ext.disk_bytenr == 0 {
339                    continue; // hole or inline; nothing to free
340                }
341                trans.delayed_refs.drop_data_ref(
342                    ext.disk_bytenr,
343                    ext.disk_num_bytes,
344                    ROOT_TREE_OBJECTID,
345                    entry.ino,
346                    ext.file_offset,
347                    1,
348                );
349                total_extents_freed += 1;
350            }
351
352            // Delete the FREE_SPACE_HEADER for this block group.
353            delete_one_item(
354                &mut trans,
355                &mut fs,
356                ROOT_TREE_OBJECTID,
357                &DiskKey {
358                    objectid: FREE_SPACE_OBJECTID,
359                    key_type: KeyType::from_raw(FREE_SPACE_HEADER_KEY_TYPE),
360                    offset: *bg_start,
361                },
362            )?;
363
364            // Delete every EXTENT_DATA item for the cache inode.
365            for ext in &entry.extents {
366                delete_one_item(
367                    &mut trans,
368                    &mut fs,
369                    ROOT_TREE_OBJECTID,
370                    &DiskKey {
371                        objectid: entry.ino,
372                        key_type: KeyType::ExtentData,
373                        offset: ext.file_offset,
374                    },
375                )?;
376            }
377
378            // Delete the INODE_ITEM (matches btrfs-progs which warns
379            // and continues if the item is missing).
380            let _ = delete_one_item(
381                &mut trans,
382                &mut fs,
383                ROOT_TREE_OBJECTID,
384                &DiskKey {
385                    objectid: entry.ino,
386                    key_type: KeyType::InodeItem,
387                    offset: 0,
388                },
389            );
390        }
391
392        // Mark the v1 cache as fully invalidated so the kernel won't
393        // try to load any leftover bits.
394        fs.superblock.cache_generation = u64::MAX;
395
396        trans
397            .commit(&mut fs)
398            .context("failed to commit transaction")?;
399        fs.sync().context("failed to sync to disk")?;
400
401        println!(
402            "cleared v1 free space cache on {} ({} block group(s), {} data extent(s) freed)",
403            self.device.display(),
404            entries.len(),
405            total_extents_freed,
406        );
407        Ok(())
408    }
409
410    fn clear_v2(&self) -> Result<()> {
411        let file = OpenOptions::new()
412            .read(true)
413            .write(true)
414            .open(&self.device)
415            .with_context(|| {
416                format!("failed to open '{}'", self.device.display())
417            })?;
418
419        let mut fs = Filesystem::open(file).with_context(|| {
420            format!("failed to open filesystem on '{}'", self.device.display())
421        })?;
422
423        let fst_flag = u64::from(raw::BTRFS_FEATURE_COMPAT_RO_FREE_SPACE_TREE);
424        let fst_valid_flag =
425            u64::from(raw::BTRFS_FEATURE_COMPAT_RO_FREE_SPACE_TREE_VALID);
426        let bgt_flag = u64::from(raw::BTRFS_FEATURE_COMPAT_RO_BLOCK_GROUP_TREE);
427
428        if fs.superblock.compat_ro_flags & bgt_flag != 0 {
429            bail!(
430                "cannot clear free space tree: filesystem has block-group-tree \
431                 enabled, which requires free-space-tree to mount"
432            );
433        }
434
435        if fs.superblock.compat_ro_flags & fst_flag == 0 {
436            println!("no free space tree to clear");
437            return Ok(());
438        }
439
440        let Some(fst_bytenr) = fs.root_bytenr(FREE_SPACE_TREE_OBJECTID) else {
441            // The compat_ro bit was set but no root pointer exists.
442            // Just clear the flags and write the superblock.
443            fs.superblock.compat_ro_flags &= !(fst_flag | fst_valid_flag);
444            let trans = Transaction::start(&mut fs)
445                .context("failed to start transaction")?;
446            trans
447                .commit(&mut fs)
448                .context("failed to commit transaction")?;
449            fs.sync().context("failed to sync to disk")?;
450            println!("cleared free space tree compat_ro flags");
451            return Ok(());
452        };
453
454        let mut tree_blocks = Vec::new();
455        collect_tree_blocks(&mut fs, fst_bytenr, &mut tree_blocks)
456            .context("failed to walk free space tree")?;
457
458        // Clear the compat_ro bits BEFORE the commit so the new
459        // superblock written at the end of commit no longer advertises
460        // an FST.
461        fs.superblock.compat_ro_flags &= !(fst_flag | fst_valid_flag);
462
463        let mut trans = Transaction::start(&mut fs)
464            .context("failed to start transaction")?;
465
466        for &(bytenr, level) in &tree_blocks {
467            trans.delayed_refs.drop_ref(
468                bytenr,
469                true,
470                FREE_SPACE_TREE_OBJECTID,
471                level,
472            );
473            trans.pin_block(bytenr);
474            fs.evict_block(bytenr);
475        }
476
477        // Delete the ROOT_ITEM for the FST from the root tree.
478        let root_key = DiskKey {
479            objectid: FREE_SPACE_TREE_OBJECTID,
480            key_type: KeyType::RootItem,
481            offset: 0,
482        };
483        let mut path = BtrfsPath::new();
484        let found = search::search_slot(
485            Some(&mut trans),
486            &mut fs,
487            ROOT_TREE_OBJECTID,
488            &root_key,
489            &mut path,
490            SearchIntent::Delete,
491            true,
492        )
493        .context("failed to search root tree for free space tree entry")?;
494
495        if found {
496            let leaf = path.nodes[0].as_mut().ok_or_else(|| {
497                anyhow::anyhow!("no leaf in path for root tree deletion")
498            })?;
499            items::del_items(leaf, path.slots[0], 1);
500            fs.mark_dirty(leaf);
501        }
502        path.release();
503
504        // Drop the FST from the in-memory roots map so the commit's
505        // update_free_space_tree pass early-returns (no FST root) and
506        // the commit doesn't try to write a ROOT_ITEM we just deleted.
507        fs.remove_root(FREE_SPACE_TREE_OBJECTID);
508
509        trans
510            .commit(&mut fs)
511            .context("failed to commit transaction")?;
512        fs.sync().context("failed to sync to disk")?;
513
514        println!(
515            "cleared free space tree on {} ({} blocks freed), kernel will rebuild it on next mount",
516            self.device.display(),
517            tree_blocks.len()
518        );
519        Ok(())
520    }
521}