]> git.hungrycats.org Git - bees/commitdiff
scan_next: detect snapshot families for the sharing cost model
authorZygo Blaxell <bees@furryterror.org>
Wed, 5 Aug 2026 16:31:07 +0000 (12:31 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:14 +0000 (00:04 -0400)
Undiverged snapshots share metadata pages, so k refs to the same
(inode, offset) through k subvols of one snapshot family are one
physical metadata item, not k items.  The space-debt model needs to
know which subvols can share pages — and with which other subvols,
since grouping refs by (inode, offset) is only sound within one
family.

Add scan_next_snap_cluster_map(): union-find over root_item uuid
values, joining each subvol's uuid to its nonzero parent_uuid.
Joining uuid values rather than subvols makes the sibling rule
automatic — two children of a deleted parent meet at the parent's
uuid with no surviving subvol needed.  Only families with two or more
existing members are published (root id -> lowest member root id), so
a no-snapshots filesystem carries one empty map.  A deleted middle
link in a snapshot-of-a-snapshot chain breaks the join; accepted,
since real topologies are stars or rolling chains where the last two
generations coexist.  scan_next_snap_extra() computes the resulting
undercharge: sum of (k - 1) over groups of identical (cluster, inode,
offset) keys.

Expose the map through the extent layer as snap_cluster(root):
default 0 (no families), btrfs base builds it lazily from one
ROOT_ITEM sweep under its own mutex and bulk-clears it at transid
change (snapshot create/delete only becomes visible at a commit, so
per-transid granularity is exact), overlay delegates to its parent.
Both helpers are layer-free and pinned by unit tests covering the
star, orphan-sibling, sole-survivor, chain, deleted-middle,
independent-family, and legacy zero-uuid cases.

Assisted-by: Claude-Code:claude-fable-5
src/bees-extent-layer.cc
src/bees-extent-layer.h
src/bees-plan.cc
src/bees-plan.h
test/test-bees-plan.cc

index d7932f51ad5e086e06044ad46a80032f651e1a02..1ccae6ac79a139c5e9379d34b0e5f89a2ab049de 100644 (file)
@@ -8,6 +8,7 @@
 #include "bees-extent-tree.h"
 #include "bees-extent.h"
 #include "bees-fdcache.h"
+#include "bees-plan.h"          // scan_next_snap_cluster_map
 #include "bees.h"
 
 #include "crucible/btrfs-tree.h"
@@ -957,6 +958,53 @@ BeesBtrfsExtentLayer::invalidate()
                << ", ref cache with " << ref_cache_drop.size() << " inodes, " << ref_count << " refs");
        BEESCOUNT(layer_extent_clear);
        BEESCOUNT(layer_ref_clear);
+
+       // Snapshot families change only at a commit, so the cluster map is
+       // exact for a whole transid cycle; drop it with the other caches.
+       {
+               unique_lock<mutex> snap_lock(m_snap_cluster_mutex);
+               m_snap_clusters_built = false;
+               m_snap_clusters.clear();
+       }
+}
+
+uint64_t
+BeesBtrfsExtentLayer::snap_cluster(uint64_t root) const
+{
+       BEESTRACE("snap_cluster root " << root);
+       unique_lock<mutex> lock(m_snap_cluster_mutex);
+       if (!m_snap_clusters_built) {
+               BEESNOTE("building snapshot cluster map");
+               // One ROOT_ITEM sweep of the root tree.  Only subvol roots
+               // (the top-level fs tree and ids in the first-free range)
+               // participate; per-fs internal trees also have ROOT_ITEMs
+               // but never own file extent refs.
+               vector<ScanNextSnapRoot> roots;
+               BtrfsRootFetcher fetcher(m_ctx.root_fd());
+               fetcher.type(BTRFS_ROOT_ITEM_KEY);
+               for (auto bti = fetcher.lower_bound(BTRFS_FS_TREE_OBJECTID);
+                    !!bti; bti = fetcher.next(bti.objectid())) {
+                       const auto objectid = bti.objectid();
+                       if (objectid == BTRFS_FS_TREE_OBJECTID ||
+                           (objectid >= BTRFS_FIRST_FREE_OBJECTID &&
+                            objectid <= BTRFS_LAST_FREE_OBJECTID)) {
+                               roots.push_back(ScanNextSnapRoot{
+                                       objectid, bti.root_uuid(),
+                                       bti.root_parent_uuid() });
+                       }
+                       if (objectid >= BTRFS_LAST_FREE_OBJECTID) {
+                               break;
+                       }
+               }
+               m_snap_clusters = scan_next_snap_cluster_map(roots);
+               m_snap_clusters_built = true;
+               BEESLOGDEBUG("snap_cluster map built: " << roots.size()
+                       << " subvols, " << m_snap_clusters.size()
+                       << " in multi-member families");
+               BEESCOUNT(layer_snap_cluster_build);
+       }
+       const auto found = m_snap_clusters.find(root);
+       return found == m_snap_clusters.end() ? 0 : found->second;
 }
 
 // BeesBtrfsExtentLayer::extent_at_bytenr() is defined in
@@ -1212,6 +1260,14 @@ BeesOverlayLayer::BeesOverlayLayer(shared_ptr<BeesExtentLayer> parent) :
 
 Fd BeesOverlayLayer::open_ref(const BeesRef &ref) const { return m_parent->open_ref(ref); }
 
+uint64_t
+BeesOverlayLayer::snap_cluster(uint64_t root) const
+{
+       // The btrfs base owns the map; overlays add nothing — planned
+       // mutations never create or destroy subvols.
+       return m_parent->snap_cluster(root);
+}
+
 void BeesOverlayLayer::readonly_fetch(const BeesRef &ref) const       { m_parent->readonly_fetch(ref); }
 void BeesOverlayLayer::extent_data_fetch(const BeesRef &ref) const    { m_parent->extent_data_fetch(ref); }
 void BeesOverlayLayer::eof_fetch(const BeesRef &ref) const            { m_parent->eof_fetch(ref); }
index 10d161205c047f0adb07b7e3818d22c22a171b9b..6b9f22928d780f4e0e347043e1eb0c0d096c651e 100644 (file)
@@ -153,6 +153,18 @@ public:
        /// do not cache).
        virtual bool has_cached_extent(uint64_t) const { return false; }
 
+       // -- Snapshot families --
+
+       /// Return the snapshot-family cluster id of subvol @p root:
+       /// nonzero and shared by every member of one snapshot family
+       /// (subvols connected through root_item uuid / parent_uuid links)
+       /// with at least two existing members; 0 when the subvol shares
+       /// metadata pages with no other existing subvol.  Consumed by the
+       /// snapshot-sharing space-debt term, which groups a dst's refs by
+       /// (cluster, inode, offset) to find refs that are one physical
+       /// shared metadata item.  Default: 0 (no snapshot families).
+       virtual uint64_t snap_cluster(uint64_t) const { return 0; }
+
        // -- Cache invalidation --
 
        /// Invalidate all cached state for the extent at @p bytenr.
@@ -227,6 +239,12 @@ public:
 
        Fd open_ref(const BeesRef &ref) const override;
 
+       /// Lazy lookup in the snapshot-family cluster map, built on first
+       /// query from one ROOT_ITEM sweep of the root tree and bulk-cleared
+       /// at transid change — snapshot creation and deletion only become
+       /// visible at a commit, so per-transid granularity is exact.
+       uint64_t snap_cluster(uint64_t root) const override;
+
        /// Per-extent cache eviction.  Removes the entry for @p bytenr
        /// from the base extent cache so that the next lookup re-reads
        /// the authoritative state from btrfs.
@@ -301,6 +319,19 @@ private:
        /// releases the refs immediately.
        mutable map<pair<uint64_t, uint64_t>,
                map<uint64_t, BeesRef>> m_inode_ref_cache;
+
+       /// Guards m_snap_clusters + m_snap_clusters_built.  Unlike
+       /// m_cache_mutex this IS held across the build's TREE_SEARCH
+       /// sweep: the map is built at most once per transid cycle and
+       /// only snap_cluster() callers block on it.
+       mutable mutex m_snap_cluster_mutex;
+       mutable bool m_snap_clusters_built = false;
+
+       /// root id -> snapshot-family cluster id, multi-member families
+       /// only (scan_next_snap_cluster_map).  Absence means "shares
+       /// metadata pages with no other subvol".  Bulk-cleared at transid
+       /// change like the other layer caches.
+       mutable map<uint64_t, uint64_t> m_snap_clusters;
 };
 
 /// Per-planner overlay layer — ephemeral working set on top of a base.
@@ -349,6 +380,9 @@ public:
 
        Fd open_ref(const BeesRef &ref) const override;
 
+       /// Delegates to the parent layer (the btrfs base owns the map).
+       uint64_t snap_cluster(uint64_t root) const override;
+
        // -- Ref map queries --
 
        /// Look up which extent owns a given ref.  Returns 0 if unknown.
index cca41a298160ead0a3f7ca5a3bfbde5de98509e0..779d012da1b6ddb01e889503494b1660d3d5b5fa 100644 (file)
@@ -4,6 +4,7 @@
 #include "crucible/string.h"  // to_hex, pretty
 
 #include <algorithm>
+#include <functional>
 
 using namespace crucible;
 using namespace std;
@@ -91,6 +92,78 @@ scan_next_debt_fields(uint64_t dst_ref_count, size_t frag_count, size_t copy_ops
        return c;
 }
 
+map<uint64_t, uint64_t>
+scan_next_snap_cluster_map(const vector<ScanNextSnapRoot> &roots)
+{
+       using Uuid = array<uint8_t, 16>;
+       static const Uuid zero_uuid = {};
+
+       // Union-find over uuid values.  Elements exist implicitly (an
+       // unseen uuid is its own set); joins insert parent pointers, find
+       // path-compresses.  Joining uuid values rather than subvols makes
+       // the sibling rule automatic: two children of a deleted parent both
+       // join the parent's uuid, which needs no surviving subvol to exist.
+       map<Uuid, Uuid> up;
+       function<Uuid(const Uuid &)> find = [&up, &find](const Uuid &x) -> Uuid {
+               const auto it = up.find(x);
+               if (it == up.end() || it->second == x) {
+                       return x;
+               }
+               const auto root = find(it->second);
+               it->second = root;
+               return root;
+       };
+
+       for (const auto &r : roots) {
+               if (r.m_uuid == zero_uuid || r.m_parent_uuid == zero_uuid) {
+                       continue;
+               }
+               const auto ra = find(r.m_uuid);
+               const auto rb = find(r.m_parent_uuid);
+               if (!(ra == rb)) {
+                       up[ra] = rb;
+               }
+       }
+
+       // Collect each family's subvol members.  A zero-uuid legacy subvol
+       // can still be a member through its parent_uuid; with neither field
+       // it has no identity to cluster on.
+       map<Uuid, vector<uint64_t>> members;
+       for (const auto &r : roots) {
+               if (!(r.m_uuid == zero_uuid)) {
+                       members[find(r.m_uuid)].push_back(r.m_root_id);
+               } else if (!(r.m_parent_uuid == zero_uuid)) {
+                       members[find(r.m_parent_uuid)].push_back(r.m_root_id);
+               }
+       }
+
+       // Publish only multi-member families — the subvols that can share
+       // metadata pages with another existing subvol.  The cluster id is
+       // the family's lowest root id: stable within one sweep, nonzero by
+       // construction (root ids start at BTRFS_FS_TREE_OBJECTID).
+       map<uint64_t, uint64_t> rv;
+       for (const auto &fam : members) {
+               if (fam.second.size() < 2) {
+                       continue;
+               }
+               const auto cluster_id =
+                       *min_element(fam.second.begin(), fam.second.end());
+               for (const auto root_id : fam.second) {
+                       rv[root_id] = cluster_id;
+               }
+       }
+       return rv;
+}
+
+uint64_t
+scan_next_snap_extra(vector<array<uint64_t, 3>> keys)
+{
+       // sum (k - 1) over groups == total keys - distinct keys.
+       sort(keys.begin(), keys.end());
+       const auto distinct = unique(keys.begin(), keys.end()) - keys.begin();
+       return keys.size() - static_cast<uint64_t>(distinct);
+}
+
 size_t
 scan_next_region_block_count(bool unreachable, uint64_t byte_count,
        uint64_t block_size)
index 0f98c91a690ee2e84eaa42b5ed76a34f5682524c..a179ac1177d39f94a0bcfd69ddd5c3a85e501edf 100644 (file)
 #include "bees.h"              // BeesRewritePolicy
 #include "bees-extent.h"       // BeesExtent
 
+#include <array>
 #include <cstdint>
 #include <list>
+#include <map>
 #include <memory>
 #include <optional>
 #include <ostream>
@@ -248,6 +250,48 @@ scan_next_debt_fields(uint64_t dst_ref_count, size_t frag_count, size_t copy_ops
        bool dst_compressed, uint64_t dst_phys_size,
        uint64_t good_blocks, uint64_t clone_alignment);
 
+/// One subvol's identity for snapshot-family clustering
+/// (snapshot-metadata-cost.md §2): the root id plus the uuid and
+/// parent_uuid octets from its ROOT_ITEM.  Zero-filled uuids mean the
+/// field is absent (legacy short root item, or not a snapshot).
+struct ScanNextSnapRoot {
+       uint64_t m_root_id = 0;
+       std::array<uint8_t, 16> m_uuid = {};
+       std::array<uint8_t, 16> m_parent_uuid = {};
+};
+
+/// Compute the snapshot-family cluster map (snapshot-metadata-cost.md §2)
+/// from every existing subvol's ROOT_ITEM identity.  Subvols are joined
+/// into one family through uuid links: a subvol's own uuid and its nonzero
+/// parent_uuid name the same shared page lineage, so union-find over uuid
+/// values connects parents to children and siblings to each other even
+/// when the common parent has been deleted (its uuid survives as the
+/// phantom join point in the children's parent_uuid).  A deleted middle
+/// link (snapshot-of-a-snapshot chains) breaks the join — the accepted
+/// miss; see the design note.
+///
+/// Returns root_id -> cluster_id for subvols whose family has at least
+/// two existing members — the subvols that can share metadata pages with
+/// another subvol.  Singleton-family subvols are absent (callers read
+/// absence as "no sharing"), so the common no-snapshots filesystem costs
+/// one empty map.  cluster_id is the lowest member root_id, nonzero by
+/// construction.  Zero-uuid legacy subvols participate only through a
+/// nonzero parent_uuid; with neither field they cannot be clustered.
+std::map<uint64_t, uint64_t>
+scan_next_snap_cluster_map(const std::vector<ScanNextSnapRoot> &roots);
+
+/// Snapshot-sharing metadata undercharge (snapshot-metadata-cost.md §3):
+/// sum of (k - 1) over groups of identical keys.  Each key is a
+/// (cluster_id, inode, offset) triple for one dst ref whose subvol
+/// belongs to a multi-member snapshot family; k identical keys are one
+/// physical metadata item seen through k subvols, so replacing the group
+/// writes k private copies but removes only the one shared item — the
+/// per-logical-ref accounting undercharges it by exactly k - 1,
+/// independent of the fragment count.  Refs outside any family (cluster
+/// 0) must not be passed in; their accounting is already correct.
+uint64_t
+scan_next_snap_extra(std::vector<std::array<uint64_t, 3>> keys);
+
 /// Whole-block count of a block-map region's byte span for cost metrics.
 /// Rounds @p byte_count up to a whole block, except an unreachable region,
 /// which rounds down.  The unaligned-EOF clip in block_map_fetch is the only
index c0a56f04eebf8f4a6fd58e1ae0823bc923cc102b..c80465fd036f7094ad0c800b7ea18049cf7bf135 100644 (file)
@@ -1076,6 +1076,152 @@ test_region_block_count_eof_tail()
        assert(buggy.space_debt(policy) == -3787.0);
 }
 
+// ----------------------------------------------------------------------
+// Snapshot-family clustering and the snapshot-sharing undercharge
+// (snapshot-metadata-cost.md §2/§3).
+
+static array<uint8_t, 16>
+uuid_of(uint8_t tag)
+{
+       array<uint8_t, 16> rv = {};
+       rv[0] = tag;
+       return rv;
+}
+
+static void
+test_snap_cluster_map()
+{
+       const auto zero = array<uint8_t, 16>{};
+
+       // Star: parent A (root 256) with snapshots B (257) and C (258).
+       // One family, cluster id = lowest member root id.
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 256, uuid_of(1), zero },
+                       { 257, uuid_of(2), uuid_of(1) },
+                       { 258, uuid_of(3), uuid_of(1) },
+               });
+               assert(m.size() == 3);
+               assert(m.at(256) == 256);
+               assert(m.at(257) == 256);
+               assert(m.at(258) == 256);
+       }
+
+       // Orphan siblings: Y and Z snapshotted from deleted X.  Their
+       // parent_uuid is the phantom join point; both cluster together.
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 300, uuid_of(4), uuid_of(9) },
+                       { 301, uuid_of(5), uuid_of(9) },
+               });
+               assert(m.size() == 2);
+               assert(m.at(300) == 300);
+               assert(m.at(301) == 300);
+       }
+
+       // Sole survivor: snapshot whose parent is deleted, no siblings.
+       // Singleton family — absent from the map (no sharing possible).
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 400, uuid_of(6), uuid_of(9) },
+                       { 401, uuid_of(7), zero },
+               });
+               assert(m.empty());
+       }
+
+       // Chain D -> E -> F, all present: transitively one family.
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 500, uuid_of(1), zero },
+                       { 501, uuid_of(2), uuid_of(1) },
+                       { 502, uuid_of(3), uuid_of(2) },
+               });
+               assert(m.size() == 3);
+               assert(m.at(502) == 500);
+       }
+
+       // Chain with deleted middle: D and F survive but no surviving uuid
+       // links them — the accepted miss (both are singleton families).
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 500, uuid_of(1), zero },
+                       { 502, uuid_of(3), uuid_of(2) },
+               });
+               assert(m.empty());
+       }
+
+       // Two independent families stay distinct clusters.
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 256, uuid_of(1), zero },
+                       { 257, uuid_of(2), uuid_of(1) },
+                       { 600, uuid_of(3), zero },
+                       { 601, uuid_of(4), uuid_of(3) },
+               });
+               assert(m.at(256) == 256 && m.at(257) == 256);
+               assert(m.at(600) == 600 && m.at(601) == 600);
+       }
+
+       // Legacy zero-uuid subvols: no identity, never clustered — and the
+       // zero uuid must not act as a shared join value between them.
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 700, zero, zero },
+                       { 701, zero, zero },
+               });
+               assert(m.empty());
+       }
+
+       // Zero-uuid subvols with the same nonzero parent_uuid are still
+       // siblings — membership through parent_uuid alone.
+       {
+               const auto m = scan_next_snap_cluster_map({
+                       { 800, zero, uuid_of(8) },
+                       { 801, zero, uuid_of(8) },
+               });
+               assert(m.size() == 2);
+               assert(m.at(800) == 800);
+               assert(m.at(801) == 800);
+       }
+}
+
+static void
+test_snap_extra_grouping()
+{
+       // No clustered refs: no undercharge.
+       assert(scan_next_snap_extra({}) == 0);
+
+       // One group of k=4 (the design's worked cases at 4 sharing
+       // subvols): extra = k - 1 = 3.
+       assert(scan_next_snap_extra({
+               { 1, 10, 0 }, { 1, 10, 0 }, { 1, 10, 0 }, { 1, 10, 0 },
+       }) == 3);
+
+       // Two k=4 groups (two refs replaced): 3 + 3 = 6.
+       assert(scan_next_snap_extra({
+               { 1, 10, 0 }, { 1, 10, 0 }, { 1, 10, 0 }, { 1, 10, 0 },
+               { 1, 10, 4096 }, { 1, 10, 4096 }, { 1, 10, 4096 }, { 1, 10, 4096 },
+       }) == 6);
+
+       // k=1 groups contribute nothing: a ref unique within its family
+       // (the others diverged that file) is charged normally.
+       assert(scan_next_snap_extra({
+               { 1, 10, 0 }, { 1, 11, 0 }, { 1, 10, 8192 },
+       }) == 0);
+
+       // Same (inode, offset) in different clusters must not merge —
+       // unrelated snapshot families share no pages.
+       assert(scan_next_snap_extra({
+               { 1, 10, 0 }, { 2, 10, 0 },
+       }) == 0);
+
+       // Mixed: one k=3 group plus singletons.
+       assert(scan_next_snap_extra({
+               { 1, 10, 0 }, { 1, 10, 0 }, { 1, 10, 0 },
+               { 1, 12, 0 }, { 2, 10, 0 },
+       }) == 2);
+}
+
 int
 main(int, char **)
 {
@@ -1104,5 +1250,7 @@ main(int, char **)
        RUN_A_TEST(test_ref_op_cost_prices_whole_extent_dedupe());
        RUN_A_TEST(test_debt_fields_shared_builder());
        RUN_A_TEST(test_search_prefers_high_ref_src());
+       RUN_A_TEST(test_snap_cluster_map());
+       RUN_A_TEST(test_snap_extra_grouping());
        return 0;
 }