]> git.hungrycats.org Git - bees/commitdiff
extent-layer: stop dedupe from leaving refs that describe other extents
authorZygo Blaxell <bees@furryterror.org>
Tue, 1 Sep 2026 01:55:54 +0000 (21:55 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:14 +0000 (00:04 -0400)
A BeesRef is a (root, inum, offset) tuple; its geometry is fetched
lazily from whatever EXTENT_DATA item sits at that file offset.  A
successful dedupe redirects the dst file range to src, so from that
moment every dst ref names a range another extent owns.  Nothing
noticed.  extent_data_fetch adopted the foreign item wholesale --
including overwriting m_bytenr -- and propagated its ram_bytes into the
dst extent as that extent's logical size.  block_map_fetch then pread
through those refs and published hashes for data the extent does not
contain, and the planner paired dst ranges against src ranges that hold
something else.  The kernel's own byte compare rejected the result, so
the visible symptom was "NO Dedup!" and an abandoned plan, several
extent-tree constraint failures away from the cause.

Three changes, which only work as a set:

Refuse foreign refs.  extent_data_fetch now compares the fetched
file_extent_bytenr against the owning extent and, on a mismatch, leaves
the cached fields undetermined instead of adopting them.  Callers
already skip refs whose geometry is not determined, so a redirected ref
reads as absent rather than as a lie.

Retire the dst.  A dedupe dst must take no further part in dedupe, so
evict it from every cache layer once its hashes are dropped.  Its
BeesExtent survives in the verification tracker with the ref list intact
for the delayed removal check and no block map to pay for, which is also
where the donor refs for the next part come from.

Repair the src.  Invalidating the src ref list is not enough on its own:
LOGICAL_INO answers from the commit root whenever it cannot attach to a
running transaction (btrfs_attach_transaction returns -ENOENT, and
find_parent_nodes then sets search_commit_root and skips delayed refs),
so a dedupe that has not reached a commit can be invisible to a refetch,
with nothing in the result to say so.  bees knows what it just created,
so repair_refs() adds the redirected dst refs to the src list directly.
Donors that btrfs already reported are skipped as duplicates, and donors
belonging to a different src are harmless because the first change
leaves them undetermined.

The guard alone would shrink ref lists and cost dedupe coverage; the
repair alone would add refs the old fetch path could still misresolve.

A 4h45m run over a 176G corpus on the reference test filesystem, one
million dedupe operations, produced 17 "NO Dedup!" events, 30
inconsistent-logical-size failures and 5 self-overlapping range pairs.
Reading both sides back at failure time showed 15 of the 17 differing in
95-99% of their bytes from the first byte or near it -- unrelated data,
not misaligned data -- and none of them compared equal on readback, so
these were not races between the compare and the ioctl.

Assisted-by: Claude-Code:claude-opus-5
src/bees-extent-layer.cc
src/bees-extent-layer.h
src/bees-scan-next.cc
test/test-bees-borrower.cc

index 1ccae6ac79a139c5e9379d34b0e5f89a2ab049de..923002eeabb903341b81e696757c7dded48c87a7 100644 (file)
@@ -39,6 +39,12 @@ BeesExtentLayer::invalidate_refs(const BeesExtent &)
        // Default no-op — base layers without per-extent ref caches.
 }
 
+void
+BeesExtentLayer::repair_refs(const BeesExtent &, const vector<BeesRef> &)
+{
+       // Default no-op — layers that do not cache ref lists.
+}
+
 Fd
 BeesExtentLayer::open_ref(const BeesRef &ref) const
 {
@@ -99,6 +105,31 @@ BeesBtrfsExtentLayer::extent_data_fetch(const BeesRef &ref) const
        if (!bti) {
                return;
        }
+       // A ref is a (root, inum, offset) tuple, and the EXTENT_DATA item at
+       // that file offset is whatever owns the range *now*.  If the range has
+       // been redirected to another extent since the ref list was built --
+       // which a dedupe does to its dst on every successful operation -- the
+       // item found here belongs to a different extent entirely.  Adopting
+       // its geometry silently reattributes another extent's ram_bytes,
+       // extent_offset and data to this one: block_map_fetch then preads the
+       // wrong file range and publishes hashes for data this extent does not
+       // contain, and the mismatch only surfaces later as a "NO Dedup!" when
+       // the kernel byte-compares what the planner paired.  Leave the cached
+       // fields unset instead.  Callers already skip refs whose geometry is
+       // not determined, so a foreign ref reads as absent rather than wrong.
+       {
+               auto owner_sp = node->m_extent.lock();
+               auto *owner = owner_sp ? dynamic_cast<BeesBtrfsExtentNode *>(owner_sp.get()) : nullptr;
+               if (owner && bti.file_extent_bytenr() != owner->m_bytenr) {
+                       BEESCOUNT(ref_foreign);
+                       BEESLOGC(DEBUG, Plan, "extent_data_fetch: ref root " << ref.root()
+                               << " inum " << ref.inum() << " offset " << to_hex(ref.offset())
+                               << " now belongs to bytenr " << to_hex(bti.file_extent_bytenr())
+                               << ", not " << to_hex(owner->m_bytenr) << "; leaving undetermined");
+                       return;
+               }
+       }
+
        {
                unique_lock<recursive_mutex> lock(node->m_lazy_mutex);
                node->m_prealloc      = (bti.file_extent_type() == BTRFS_FILE_EXTENT_PREALLOC);
@@ -935,6 +966,68 @@ BeesBtrfsExtentLayer::invalidate_refs(const BeesExtent &extent)
        }
 }
 
+void
+BeesBtrfsExtentLayer::repair_refs(const BeesExtent &extent, const vector<BeesRef> &donors)
+{
+       const auto bytenr_o = extent.bytenr_opt();
+       if (!bytenr_o || donors.empty()) {
+               return;
+       }
+       auto *node = dynamic_cast<BeesBtrfsExtentNode *>(extent.extent_sp().get());
+       if (!node) {
+               return;
+       }
+
+       // Refetch first so the repair is applied on top of whatever btrfs is
+       // willing to report right now.
+       auto fetched = atomic_load(&node->m_refs);
+       if (!fetched) {
+               fetched = refs_fetch(*node);
+       }
+
+       // LOGICAL_INO answers from the commit root whenever it cannot attach
+       // to a running transaction (fs/btrfs/backref.c: btrfs_attach_transaction
+       // returns -ENOENT, find_parent_nodes then sets search_commit_root and
+       // skips delayed refs).  A dedupe performed since the last commit is
+       // therefore invisible to a refetch that happens to land in a lull, and
+       // nothing in the result distinguishes that case from a genuinely
+       // short ref list.  We know what we just created, so do not ask: the
+       // donors are the dst refs the dedupe redirected onto this extent.
+       // Adding one that btrfs already reported is harmless (skipped as a
+       // duplicate); adding one belonging to a different src is harmless too,
+       // because extent_data_fetch leaves a foreign ref undetermined and
+       // every caller skips it.
+       auto merged = *fetched;
+       size_t added = 0;
+       for (const auto &donor : donors) {
+               const auto root = donor.root();
+               const auto inum = donor.inum();
+               const auto offset = donor.offset();
+               bool present = false;
+               for (const auto &have : merged) {
+                       if (have.root() == root && have.inum() == inum &&
+                           have.offset() == offset) {
+                               present = true;
+                               break;
+                       }
+               }
+               if (present) {
+                       continue;
+               }
+               auto ref_node = make_shared<BeesBtrfsRefNode>(root, inum, offset);
+               ref_node->m_bytenr = *bytenr_o;
+               ref_node->m_extent = extent.extent_sp();
+               merged.push_back(BeesRef(static_pointer_cast<BeesBaseRefNode>(ref_node)));
+               ++added;
+       }
+       if (!added) {
+               return;
+       }
+       BEESCOUNTADD(ref_repaired, added);
+       atomic_store(&node->m_refs,
+               make_shared<const vector<BeesRef>>(std::move(merged)));
+}
+
 void
 BeesBtrfsExtentLayer::invalidate()
 {
@@ -1540,3 +1633,11 @@ BeesOverlayLayer::invalidate_refs(const BeesExtent &extent)
        // Propagate so the base layer drops its per-inode ref cache too.
        m_parent->invalidate_refs(extent);
 }
+
+void
+BeesOverlayLayer::repair_refs(const BeesExtent &extent, const vector<BeesRef> &donors)
+{
+       // The overlay caches no ref lists of its own; the list lives on the
+       // node.  Forward so the base layer performs the merge.
+       m_parent->repair_refs(extent, donors);
+}
index 6b9f22928d780f4e0e347043e1eb0c0d096c651e..ea978cec4d3f93b2ff4c2ebdfdac2a8f33687c3e 100644 (file)
@@ -190,6 +190,15 @@ public:
        /// returns without doing anything.  Default: no-op.
        virtual void invalidate_refs(const BeesExtent &extent);
 
+       /// Restore refs to @p extent that a just-completed dedupe created
+       /// but a refetch may not report.  @p donors are the dst-side refs
+       /// the dedupe redirected onto this extent; each is added to the
+       /// extent's ref list if a (root, inum, offset) match is not already
+       /// present.  Needed because LOGICAL_INO falls back to the commit
+       /// root when it cannot attach to a running transaction, making
+       /// uncommitted dedupes invisible to it.  Default: no-op.
+       virtual void repair_refs(const BeesExtent &extent, const vector<BeesRef> &donors);
+
        // -- Transitional: BeesContext access --
 
        /// Return the underlying BeesContext.  Only valid on layers
@@ -260,6 +269,7 @@ public:
        /// Leaves m_extent_cache entries in place because the extent
        /// identity (bytenr + phys_size) is still valid after dedupe.
        void invalidate_refs(const BeesExtent &extent) override;
+       void repair_refs(const BeesExtent &extent, const vector<BeesRef> &donors) override;
 
        BeesContext &ctx() const override;
 
@@ -424,6 +434,7 @@ public:
        /// removes the extent's own neighbor entries plus back-edges from
        /// other extents' neighbor sets.  Propagates to the parent.
        void invalidate_refs(const BeesExtent &extent) override;
+       void repair_refs(const BeesExtent &extent, const vector<BeesRef> &donors) override;
 
 private:
        shared_ptr<BeesExtentLayer> m_parent;
index 29341a7fba9d625b4ff24342383f01f7d8691de4..fed134e732aeb9316d4aba900bd417bec5aff34b 100644 (file)
@@ -4048,11 +4048,16 @@ Planner::run(const BtrfsTreeItem &bti,
        // - dst extents lose refs (may disappear entirely).  Their
        //   bytenr-indexed hash-table cells are now stale — erase
        //   proactively so later discovery does not chase a removed
-       //   extent.  Then drop in-memory block_map + hashes.  Identity
-       //   and refs stay cached because they remain valid for the
-       //   current ref-count and for the post-removal verification
-       //   cycle.  Hash tables are not a layered cache, so no layer
-       //   call is required for the dst path.
+       //   extent.  Then drop in-memory block_map + hashes and evict
+       //   the extent from every cache layer, so nothing can reach it
+       //   by bytenr again.  A post-dedupe dst must take no further
+       //   part in dedupe: its refs name file ranges that now belong
+       //   to src, and resolving one yields another extent's geometry
+       //   and another file's data.  The BeesExtent object survives
+       //   eviction -- the verification tracker holds it, ref list
+       //   intact for the delayed removal check, block map dropped so
+       //   the wait costs no memory.  Those same refs are then the
+       //   donor list for repairing the src side.
        // - src extents gain refs to the deduped ranges.  Block contents
        //   (and therefore hashes) are unchanged, but every ref-derived
        //   cache entry is now stale: overlay ref map + neighbor edges,
@@ -4071,11 +4076,19 @@ Planner::run(const BtrfsTreeItem &bti,
                        }
                }
                // Drop node-local cached hashes (m_full_block_map +
-               // m_zero_eliminated).  No layer call: hashes are not
-               // layered.
+               // m_zero_eliminated).  Hashes are not layered, so this
+               // needs no layer call of its own.
                dst.invalidate_hashes();
+               // Evict from the overlay and, through it, the shared base:
+               // drops the m_extent_cache entry and every m_inode_ref_cache
+               // entry naming this bytenr.  Deliberately not
+               // invalidate_refs(): the node keeps its own ref list for the
+               // verification cycle, now its only holder.
+               layer.invalidate(dst_bytenr);
                m_filter_cache->invalidate(dst_bytenr);
        };
+       // Dst refs redirected by this plan, used to repair the src lists.
+       vector<BeesRef> donor_refs;
        auto invalidate_src_refs = [&](uint64_t src_bytenr) {
                // Pull the extent from the layer cache — identity is
                // still valid, so this is usually a free hit.
@@ -4086,9 +4099,27 @@ Planner::run(const BtrfsTreeItem &bti,
                        // that hits the overlay first sees no stale state.
                        layer.invalidate_refs(src);
                        src.invalidate_refs();
+                       // Refetching alone is not enough.  LOGICAL_INO answers
+                       // from the commit root whenever it cannot attach to a
+                       // running transaction, so a dedupe that has not reached
+                       // a commit can be invisible to the refetch, and the
+                       // result does not say which case it was.  Hand it the
+                       // dst refs the dedupe just redirected here so the list
+                       // is complete either way.
+                       layer.repair_refs(src, donor_refs);
                }
                m_filter_cache->invalidate(src_bytenr);
        };
+       // Collect donors before evicting the dst extents: eviction leaves
+       // the node's ref list alone, but the list is what we need and
+       // reading it first keeps the dependency obvious.
+       for (const auto &dst_plan : plan.m_dst_plans) {
+               const auto dst_refs = dst_plan.m_dst.refs(&layer);
+               if (dst_refs) {
+                       donor_refs.insert(donor_refs.end(),
+                               dst_refs->begin(), dst_refs->end());
+               }
+       }
        for (const auto &dst_plan : plan.m_dst_plans) {
                invalidate_dst_hashes(dst_plan.m_dst);
        }
index 571cfc3151a103481537328a1e90731ca73e367d..14bbfbb8a767195bd7d886f47b495be59a568ccd 100644 (file)
@@ -52,6 +52,7 @@ BeesExtentLayer::~BeesExtentLayer() = default;
 void BeesExtentLayer::invalidate(uint64_t) {}
 void BeesExtentLayer::invalidate() {}
 void BeesExtentLayer::invalidate_refs(const BeesExtent &) {}
+void BeesExtentLayer::repair_refs(const BeesExtent &, const vector<BeesRef> &) {}
 Fd BeesExtentLayer::open_ref(const BeesRef &) const { return {}; }
 
 // bees-trace.o references exception_check() from bees-lib.o; stub it here