]> git.hungrycats.org Git - bees/commitdiff
scan_next: price snapshot metadata unsharing in the space debt
authorZygo Blaxell <bees@furryterror.org>
Wed, 5 Aug 2026 16:38:50 +0000 (12:38 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:14 +0000 (00:04 -0400)
Charge the snapshot-sharing undercharge in the do-nothing cost model.
refs_added credits one removed item per rewritten ref, but when k refs
through k subvols of one snapshot family are a single shared metadata
item, replacing the group writes k*F private items and removes only
the one shared item (freed by btrfs once the last sharer replaces its
ref) — the per-logical-ref accounting is short by exactly k-1 per
group, independent of F.  Without the charge the planner performs
dedupes in snapshotted subvols whose physical win is smaller than the
metadata pages they unshare.

New CostReport::m_snap_extra carries the per-dst sum of (k-1) over
(cluster, inode, offset) ref groups, priced in space_debt() as
snap_extra * ref-cost * rewrite.snapshot-sharing.  The term is a
per-dst constant (no F or op-mix dependence), so the state-collapse
invariant holds and the covering search is unchanged; it also makes
orientation snapshot-aware — a snapshot-family extent is cheaper as
src than as dst.  Both debt paths marshal it through the shared
scan_next_debt_fields builder; the Plan B per-dst do-nothing gate and
aggregate pick it up, and the prefer-canonical-src pivot swaps the
start's and center's contributions along with their roles.

rewrite.snapshot-sharing is the expected ratio of subvol metadata
sharing: 1.0 (default) = undiverged snapshots, exactly cancelling the
invalid removal credit; 0 disables the term and the cluster-map work;
outside [0,1] scales for unusually expensive or cheap metadata.  Refs
of snapshots that diverged past matching vanish from the dst ref list,
so they stop being charged with no special handling.

The plan: line grows a trailing 'snap <N>' field (appended, so
analyze-bees-log.sh parsing is unaffected), documented in running.md.
Unit tests pin the worked example at sharing 1.0 / 0 / 10, the
disabled path, and shared-builder carriage of snap_extra.

Assisted-by: Claude-Code:claude-fable-5
docs/config-file.md
docs/running.md
src/bees-config-v2.cc
src/bees-config.cc
src/bees-plan.cc
src/bees-plan.h
src/bees-scan-next.cc
src/bees.h
test/test-bees-plan.cc

index a884d32af1fbebc0f3828be48c77c509bb9c78c2..6a707a30014a895d5368c8e4f71a0027291d5d20 100644 (file)
@@ -291,6 +291,33 @@ removed.
     unchanged until an operator opts in).
   * Accepts a [size value](config-format.md#size-values).
 
+* **`snapshot-sharing`**
+  Expected ratio of subvol metadata sharing between snapshots, weighting the
+  extra metadata cost of deduping in snapshotted subvols.  Snapshot creation
+  shares the source subvol's metadata pages with the snapshot (bees detects
+  the relationship through each subvol's `uuid` and `parent_uuid`, including
+  snapshots whose common parent has since been deleted).  While the sharing
+  lasts, `k` references to the same file range seen through `k` subvols of one
+  snapshot family are a *single* shared metadata item, and redirecting them
+  makes each subvol copy its metadata pages: `k` new items are written while
+  only the one shared item is eventually removed (btrfs frees the shared page
+  once the last subvol has replaced its reference).  The per-reference
+  accounting of `ref-cost` is short by exactly `k − 1` items per shared group;
+  this option scales that correction, adding
+  `(k − 1) × ref-cost × snapshot-sharing` per group to the plan's debt.
+  References in subvols with no snapshot relationship are unaffected, and a
+  snapshot whose data has diverged past matching no longer appears among the
+  destination's references, so it stops being charged automatically.
+  * `1.0` assumes snapshots have not diverged — every shared group still pays
+    the full correction.  `0` disables the term (and the snapshot detection
+    work) entirely.  Values below `1.0` model partially-diverged snapshots;
+    values above `1.0` additionally price expensive metadata — rotating
+    storage, or allocation-preferences setups with dedicated fixed-size
+    metadata devices, where an unshared 16K metadata page costs far more than
+    the items written into it.
+  * Default: `1.0`.
+  * Accepts a [ratio value](config-format.md#ratio-values) (e.g. `1.0`, `50%`).
+
 * **`prefer-canonical-src`**
   When several source extents match a destination equally well — they cover the
   same span, so the choice between them does not change the plan's cost — prefer
index a154fce17a1c773b5dfca4c8f2b4d4297d24ee6c..a08498cd6b083c58d812b8abd3157d9778331ce8 100644 (file)
@@ -132,7 +132,7 @@ an extent that could not be read (freed or all references stale).
 Each destination that is rewritten also prints a `plan:` line describing the
 chosen plan and why it was chosen:
 
-        plan: 128K 1d0c0p0u 7.787s/8.600s {0x2fce257000} [Dddddd] refs 9912 rc 10041 debt -7197.0
+        plan: 128K 1d0c0p0u 7.787s/8.600s {0x2fce257000} [Dddddd] refs 9912 rc 10041 debt -7197.0 snap 0
 
   * `128K` — the destination's logical size.
   * `1d0c0p0u` — operation counts for this destination: `d` dedupe, `c` copy
@@ -154,6 +154,11 @@ chosen plan and why it was chosen:
     do-nothing is exactly `0`.  A destination with a large `refs` and a negative
     `debt` was worth rewriting because the space it frees outweighs the cost of
     moving its references.
+  * `snap <N>` — the snapshot-sharing undercharge the winning plan's debt
+    carried: the number of extra metadata items unshared by rewriting
+    references in snapshotted subvols (what `rewrite.snapshot-sharing`
+    prices).  `0` when the term is disabled or no destination reference
+    shares metadata pages.
   * `pivot` — present when this destination belongs to a re-centered plan: with
     `rewrite.prefer-canonical-src`, the dedupe star was pivoted onto a
     higher-reference whole-extent duplicate, so this destination (possibly the
index 7fd1da30aa0f432af6212441c04a3b187c4fe501..891612008c45c49d96a26a7b36b93fc09f8514d4 100644 (file)
@@ -194,6 +194,12 @@ static const char bees_config_v2[] = R"--v2-config--(
         # Size value in bytes; 0 disables.  See docs/config-file.md.
         ref-op-cost = 0
 
+        # Expected ratio of subvol metadata sharing between snapshots.
+        # Weights the extra ref-cost charged when dedupe unshares snapshot
+        # metadata pages.  Decimal ratio; 1.0 = undiverged snapshots,
+        # 0 disables.  See docs/config-file.md.
+        snapshot-sharing = 1.0
+
         # Prefer the most-referenced source on equal-length dedupe matches, to
         # consolidate duplicates onto a canonical extent.  Boolean; only safe
         # with a non-zero ref-op-cost.  See docs/config-file.md.
index 03086ff4f0a88e17e7d29b048a05039ec1aada00..6a453f730d45757d63061cab1160cd3056973bfe 100644 (file)
@@ -472,6 +472,7 @@ BeesConfig::load_rewrite_policy()
        rv.ref_cost              = load("rewrite.ref-cost",            bees_parse_size);
        rv.extent_cost           = load("rewrite.extent-cost",         bees_parse_size);
        rv.m_ref_op_cost         = load("rewrite.ref-op-cost",         bees_parse_size);
+       rv.m_snapshot_sharing    = load("rewrite.snapshot-sharing",    bees_parse_ratio);
        rv.m_plan_max_vertices   = load("rewrite.plan-max-vertices",   bees_parse_count);
        rv.m_restart_max         = load("rewrite.restart-max",         bees_parse_count);
        rv.m_insert_sample_max   = load("rewrite.insert-sample-max",   bees_parse_count);
index 779d012da1b6ddb01e889503494b1660d3d5b5fa..7d9b2b4ab1a3f311ff6d4b5cf0cd4c965c03da26 100644 (file)
@@ -27,20 +27,27 @@ CostReport::cheaper_than(const CostReport &o) const
 double
 CostReport::space_debt(const BeesRewritePolicy &policy) const
 {
-       // do-nothing-plan.md §4:
+       // do-nothing-plan.md §4 plus the snapshot-sharing undercharge
+       // (snapshot-metadata-cost.md §3):
        //   debt = refs_added * ref_cost
        //        + refs_created * ref_op_cost
        //        + extents_delta * extent_cost
        //        - physical_freed
+       //        + snap_extra * ref_cost * snapshot_sharing
        // Lower is better; negative is a net win; do-nothing is 0.  Computed
        // in double so refs_added * ref_cost cannot overflow int64 before the
        // physical credit is subtracted; the result is only an ordering key,
        // so precision loss past 2^53 does not matter.  ref_op_cost defaults to
-       // 0 (the term vanishes) unless an operator opts in.
+       // 0 (the term vanishes) unless an operator opts in.  snapshot_sharing
+       // scales the per-group k-1 correction: at the default 1.0 it exactly
+       // cancels the removal credit refs_added wrongly grants shared refs,
+       // and 0 turns the term off (callers then skip computing snap_extra).
        return static_cast<double>(m_refs_added) * static_cast<double>(policy.ref_cost)
                + static_cast<double>(m_refs_created) * static_cast<double>(policy.m_ref_op_cost)
                + static_cast<double>(m_extents_delta) * static_cast<double>(policy.extent_cost)
-               - static_cast<double>(m_physical_freed);
+               - static_cast<double>(m_physical_freed)
+               + static_cast<double>(m_snap_extra) * static_cast<double>(policy.ref_cost)
+                       * policy.m_snapshot_sharing;
 }
 
 ostream &
@@ -54,6 +61,7 @@ operator<<(ostream &os, const CostReport &c)
                  << ", refs_created = " << c.m_refs_created
                  << ", extents_delta = " << c.m_extents_delta
                  << ", physical_freed = " << pretty(c.m_physical_freed)
+                 << ", snap_extra = " << c.m_snap_extra
                  << ", space_debt = " << c.m_space_debt
                  << " }";
 }
@@ -64,10 +72,15 @@ operator<<(ostream &os, const CostReport &c)
 CostReport
 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)
+       uint64_t good_blocks, uint64_t clone_alignment,
+       uint64_t snap_extra)
 {
        CostReport c;
 
+       // snap_extra: precomputed per-dst snapshot-sharing undercharge,
+       // carried through so both debt paths price it identically.
+       c.m_snap_extra = snap_extra;
+
        // refs_added = dst_ref_count * (F - 1); guard F <= 1 so it can't wrap.
        if (frag_count > 1) {
                c.m_refs_added = dst_ref_count
@@ -186,7 +199,7 @@ scan_next_covering_debt(const PlanSearchInputs &in,
                + in.m_unreachable_blocks + matched_blocks;
        const auto c = scan_next_debt_fields(in.m_dst_ref_count, frag_count,
                copy_ops, in.m_dst_compressed, in.m_dst_phys_size,
-               good_blocks, in.m_clone_alignment);
+               good_blocks, in.m_clone_alignment, in.m_snap_extra);
        return c.space_debt(policy);
 }
 
index a179ac1177d39f94a0bcfd69ddd5c3a85e501edf..043f536c7d7ff7954f8ebde18172c2ff360ae06c 100644 (file)
@@ -104,10 +104,19 @@ struct CostReport {
        /// Physical bytes the plan recovers: phys_size for a compressed dst
        /// removed wholesale, (blocks removed * blocksize) for uncompressed.
        uint64_t m_physical_freed = 0;
+       /// Snapshot-sharing metadata undercharge (snapshot-metadata-cost.md
+       /// §3): sum of (k - 1) over groups of dst refs that are one physical
+       /// shared item seen through k subvols of one snapshot family.  Those
+       /// groups cost k*F private items minus one shared removal, while
+       /// refs_added charges k*(F-1) — short by k-1 regardless of F.
+       /// Weighted by ref_cost * snapshot-sharing in space_debt(); 0 for
+       /// refs outside any family, where refs_added is already correct.
+       uint64_t m_snap_extra = 0;
 
        /// Finalized signed space debt (do-nothing-plan.md §4), in bytes:
        ///   refs_added * ref_cost + refs_created * ref_op_cost
        ///     + extents_delta * extent_cost - physical_freed
+       ///     + snap_extra * ref_cost * snapshot_sharing
        /// Lower is better; negative is a net win; do-nothing is exactly 0.0.
        /// Computed in double so refs_added * ref_cost cannot overflow before
        /// the physical credit is subtracted; the value is only an ordering
@@ -190,6 +199,7 @@ struct PlanSearchInputs {
        uint64_t m_dst_ref_count = 0;        ///< dst.refs()->size()
        bool     m_dst_compressed = false;   ///< compressed dst: no copy edges unless m_force_rewrite
        uint64_t m_dst_phys_size = 0;        ///< whole-extent physical credit when compressed
+       uint64_t m_snap_extra = 0;           ///< snapshot-sharing undercharge, per-dst constant
 
        /// Force-rewrite (filter force-rewrite = yes) lifts the compressed-dst
        /// copy prohibition: the operator has scoped, per filter, exactly which
@@ -244,11 +254,15 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
 ///   extents_delta  = +1 if any copy (one shared tempfile extent), else -1 (dst
 ///                    fully freed).  copy_ops > 0 iff unmatched data remains, so
 ///                    this single test matches the old bad_blocks==0 form.
+///   snap_extra     = passed through: the dst's snapshot-sharing undercharge
+///                    (scan_next_snap_extra of the refs' family groups), a
+///                    per-dst constant with no F dependence.
 /// @p good_blocks is the already-summed matched + zero-like + unreachable count.
 CostReport
 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);
+       uint64_t good_blocks, uint64_t clone_alignment,
+       uint64_t snap_extra);
 
 /// One subvol's identity for snapshot-family clustering
 /// (snapshot-metadata-cost.md §2): the root id plus the uuid and
index fd342d04c44c8e1d23d2dd14a95a73a2168876c5..29341a7fba9d625b4ff24342383f01f7d8691de4 100644 (file)
@@ -75,6 +75,11 @@ plan_tree_summary(const BeesExtent &plan_tree)
        return oss.str();
 }
 
+// Defined below scan_next_dst_debt; used by the anon-namespace flatten
+// path (scan_next_choose_match_plan) as well.
+static uint64_t scan_next_snap_extra_refs(BeesExtentLayer &layer,
+       const BeesRewritePolicy &policy, const BeesExtent &dst);
+
 namespace {
 
 // ExtentMatch and its list typedef live in bees-plan.h so they can be
@@ -1111,6 +1116,8 @@ scan_next_choose_match_plan(const BeesExtent &dst,
        in.m_dst_ref_count      = dst.refs(layer)->size();
        in.m_dst_compressed     = dst.compress_type(layer) != BTRFS_COMPRESS_NONE;
        in.m_dst_phys_size      = dst.phys_size();
+       in.m_snap_extra         = scan_next_snap_extra_refs(layer,
+               rewrite_policy, dst);
        in.m_force_rewrite      = force_rewrite;
        in.m_sums_block_size    = ranged_cast<uint64_t>(BLOCK_SIZE_SUMS);
        in.m_clone_alignment    = ranged_cast<uint64_t>(BeesContext::s_clone_alignment);
@@ -1790,6 +1797,12 @@ struct DstPlan {
        /// m_dst_ref_count (read fresh at build): if this is ~0 while that is large,
        /// the planner priced the dst against a stale/reduced ref view.
        uint64_t m_sel_refs_created = 0;
+       /// Snapshot-sharing undercharge the winning plan's cost() carried
+       /// (CostReport::m_snap_extra — the sum of (k-1) over snapshot-family
+       /// ref groups).  Logged as "snap" on the "plan:" line so dedupes
+       /// paying the snapshot term are greppable; 0 when the term is off or
+       /// no dst ref shares metadata pages.
+       uint64_t m_sel_snap_extra = 0;
        /// True when this dst belongs to a re-centered Plan B (prefer-canonical-src
        /// pivoted the star onto a higher-ref whole-extent duplicate, so this dst —
        /// possibly the former start — is deduped onto that canonical).  Logged as a
@@ -1979,8 +1992,39 @@ scan_next_compute_match_views(const BeesExtent &start,
 /// debt) than the unsplit edge, so the debt-minimizing selector never
 /// places it on a chosen plan.  rewrite_ops() therefore matches what
 /// btrfs materializes wherever the debt is consulted.
+/// Compute the dst's snapshot-sharing undercharge (snapshot-metadata-cost.md
+/// §3): group the refs by (snapshot-family cluster, inode, offset) and sum
+/// (k - 1) over the groups — refs that are one physical metadata item seen
+/// through k subvols.  0 when the term is disabled (snapshot-sharing <= 0,
+/// which also skips the cluster-map build) or no ref belongs to a
+/// multi-member family.  refs() and snap_cluster() are layer-cached, so
+/// repeated pricing of one dst within a transid cycle costs no ioctls.
+static uint64_t
+scan_next_snap_extra_refs(BeesExtentLayer &layer,
+       const BeesRewritePolicy &policy, const BeesExtent &dst)
+{
+       if (!(policy.m_snapshot_sharing > 0.0)) {
+               return 0;
+       }
+       const auto refs_sp = dst.refs(layer);
+       vector<array<uint64_t, 3>> keys;
+       for (const auto &ref : *refs_sp) {
+               const auto cluster = layer.snap_cluster(ref.root());
+               if (cluster == 0) {
+                       continue;
+               }
+               keys.push_back({ cluster, ref.inum(), ref.offset() });
+       }
+       const auto rv = scan_next_snap_extra(std::move(keys));
+       if (rv > 0) {
+               BEESCOUNT(plan_snap_extra);
+       }
+       return rv;
+}
+
 static CostReport
 scan_next_dst_debt(BeesExtentLayer &layer,
+       const BeesRewritePolicy &policy,
        const BeesExtent &dst, const ChosenPlan &cp)
 {
        // Marshal this dst's ChosenPlan into the shared builder:
@@ -2003,7 +2047,8 @@ scan_next_dst_debt(BeesExtentLayer &layer,
                dst_compressed,
                dst.phys_size(),
                cp.good_blocks(),
-               static_cast<uint64_t>(BeesContext::s_clone_alignment));
+               static_cast<uint64_t>(BeesContext::s_clone_alignment),
+               scan_next_snap_extra_refs(layer, policy, dst));
 }
 
 // -----------------------------------------------------------------------------
@@ -2250,11 +2295,12 @@ BeesStartAsDstPlan::ensure_selection()
        // orientation, so no separate compress-bias tiebreaker is computed.
        {
                const auto debt = scan_next_dst_debt(
-                       Borrower::current().layer(), m_start, cp);
+                       Borrower::current().layer(), m_policy, m_start, cp);
                m_cost.m_refs_added     = debt.m_refs_added;
                m_cost.m_refs_created   = debt.m_refs_created;
                m_cost.m_extents_delta  = debt.m_extents_delta;
                m_cost.m_physical_freed = debt.m_physical_freed;
+               m_cost.m_snap_extra     = debt.m_snap_extra;
        }
        m_cost.m_space_debt = m_cost.space_debt(m_policy);
 
@@ -2545,11 +2591,12 @@ BeesStartAsSrcPlan::process_candidate(
        // the plan's signed debt is finalized.
        {
                const auto debt = scan_next_dst_debt(
-                       Borrower::current().layer(), candidate, *chosen);
+                       Borrower::current().layer(), m_policy, candidate, *chosen);
                entry.m_entry_cost.m_refs_added     = debt.m_refs_added;
                entry.m_entry_cost.m_refs_created   = debt.m_refs_created;
                entry.m_entry_cost.m_extents_delta  = debt.m_extents_delta;
                entry.m_entry_cost.m_physical_freed = debt.m_physical_freed;
+               entry.m_entry_cost.m_snap_extra     = debt.m_snap_extra;
        }
 
        // Per-dst benefit gate.  Plan B executes each dst independently (each is a
@@ -2581,6 +2628,7 @@ BeesStartAsSrcPlan::process_candidate(
                m_cost.m_refs_created   += e.m_entry_cost.m_refs_created;
                m_cost.m_extents_delta  += e.m_entry_cost.m_extents_delta;
                m_cost.m_physical_freed += e.m_entry_cost.m_physical_freed;
+               m_cost.m_snap_extra     += e.m_entry_cost.m_snap_extra;
        }
 
        // Finalize the aggregate signed debt (do-nothing-plan.md §4) where
@@ -2644,7 +2692,13 @@ BeesStartAsSrcPlan::finalize()
        // physical space and the extent delta is unchanged; only the moved-
        // reference count changes, dropping by (center_refs - start_refs) — the
        // churn the pivot saves.  Adjust the aggregate debt and re-price.
+       // The snapshot-sharing undercharge swaps with the roles too: the
+       // center's refs are no longer modified (its groups drop out) and the
+       // start's refs now are (its groups are charged).
        m_cost.m_refs_created = m_cost.m_refs_created - center_refs + start_refs;
+       m_cost.m_snap_extra = m_cost.m_snap_extra
+               - center->m_entry_cost.m_snap_extra
+               + scan_next_snap_extra_refs(layer, m_policy, m_start);
        m_cost.m_space_debt = m_cost.space_debt(m_policy);
 }
 
@@ -3097,6 +3151,7 @@ scan_next_execute_dst_plan(const shared_ptr<BeesContext> &ctx,
                << bar << "] refs " << dst_plan.m_dst_ref_count
                << " rc " << dst_plan.m_sel_refs_created
                << " debt " << setprecision(1) << dst_plan.m_space_debt
+               << " snap " << dst_plan.m_sel_snap_extra
                << (dst_plan.m_pivoted ? " pivot" : ""));
 
        return true;
@@ -3340,6 +3395,7 @@ scan_next_build_extent_plan(
        const auto winner_cost = winner.cost();
        const double   winner_space_debt   = winner_cost.m_space_debt;
        const uint64_t winner_refs_created = winner_cost.m_refs_created;
+       const uint64_t winner_snap_extra   = winner_cost.m_snap_extra;
 
        if (auto *pa = dynamic_cast<const BeesStartAsDstPlan *>(&winner)) {
                const auto *impl = pa->impl();
@@ -3365,6 +3421,7 @@ scan_next_build_extent_plan(
 
                dp->m_space_debt       = winner_space_debt;
                dp->m_sel_refs_created = winner_refs_created;
+               dp->m_sel_snap_extra   = winner_snap_extra;
                dp->m_dst_ref_count    = start.refs(diag_layer)->size();
                out.m_total_cost = dp->m_cost;
                for (const auto &g : groups) {
@@ -3412,6 +3469,7 @@ scan_next_build_extent_plan(
                                        if (dp) {
                                                dp->m_space_debt       = winner_space_debt;
                                                dp->m_sel_refs_created = winner_refs_created;
+                                               dp->m_sel_snap_extra   = winner_snap_extra;
                                                dp->m_dst_ref_count = dst.refs(diag_layer)->size();
                                                dp->m_pivoted = true;
                                                out.m_total_cost += dp->m_cost;
@@ -3457,6 +3515,7 @@ scan_next_build_extent_plan(
                                if (dp) {
                                        dp->m_space_debt       = winner_space_debt;
                                        dp->m_sel_refs_created = winner_refs_created;
+                                       dp->m_sel_snap_extra   = winner_snap_extra;
                                        dp->m_dst_ref_count =
                                                entry.m_candidate.refs(diag_layer)->size();
                                        out.m_total_cost += dp->m_cost;
index 21705b1e9153cfd7906ede1189140295930ee02e..49756f39714319ee9ce189980e3a1b55f196a85d 100644 (file)
@@ -1477,6 +1477,19 @@ struct BeesRewritePolicy {
        /// reference cost.  Authoritative default lives in bees-config-v2.cc
        /// (0 = disabled); the C++ initializer is an invalid sentinel.
        uint64_t m_ref_op_cost = UINT64_MAX;
+       /// Expected ratio of subvol metadata sharing between snapshots
+       /// (snapshot-metadata-cost.md).  Weights the snapshot-sharing
+       /// undercharge: a group of k dst refs that is one physical metadata
+       /// item seen through k subvols of a snapshot family costs k*F
+       /// private items minus one shared removal when replaced, while the
+       /// per-logical-ref accounting charges k*(F-1) — short by k-1.  Each
+       /// group adds (k-1) * ref_cost * this ratio to the debt.  1.0 =
+       /// snapshots never diverged (full charge); 0 = fully diverged (term
+       /// off, cluster lookups skipped); outside [0,1] scales for unusually
+       /// expensive or cheap metadata.  Authoritative default lives in
+       /// bees-config-v2.cc; the C++ initializer (negative) is an invalid
+       /// sentinel.
+       double m_snapshot_sharing = -1.0;
        /// When several source extents match a destination equally well (they cover
        /// the same span), prefer the source that already has the most references,
        /// so duplicates consolidate onto a canonical extent instead of an
index c80465fd036f7094ad0c800b7ea18049cf7bf135..285c2a358b0b1627f801b96ad04fc03aa44ae55d 100644 (file)
@@ -57,6 +57,7 @@ default_policy()
        p.ref_cost                = 53;
        p.extent_cost             = 53;
        p.m_ref_op_cost           = 0;
+       p.m_snapshot_sharing      = 1.0;
        p.m_plan_max_vertices     = 16384;
        p.m_read_size             = 1024 * 1024;
        return p;
@@ -985,7 +986,8 @@ test_debt_fields_shared_builder()
                const auto c = scan_next_debt_fields(
                        /*dst_ref_count=*/3000, /*frag_count=*/1, /*copy_ops=*/0,
                        /*dst_compressed=*/true, /*dst_phys_size=*/131072,
-                       /*good_blocks=*/32, /*clone_alignment=*/4096);
+                       /*good_blocks=*/32, /*clone_alignment=*/4096,
+                       /*snap_extra=*/0);
                assert(c.m_refs_added == 0);            // F=1 -> no metadata growth
                assert(c.m_refs_created == 3000);       // work still priced
                assert(c.m_physical_freed == 131072);   // compressed: whole phys_size
@@ -996,7 +998,8 @@ test_debt_fields_shared_builder()
                const auto c = scan_next_debt_fields(
                        /*dst_ref_count=*/10, /*frag_count=*/3, /*copy_ops=*/1,
                        /*dst_compressed=*/false, /*dst_phys_size=*/0,
-                       /*good_blocks=*/5, /*clone_alignment=*/4096);
+                       /*good_blocks=*/5, /*clone_alignment=*/4096,
+                       /*snap_extra=*/0);
                assert(c.m_refs_added == 10 * (3 - 1)); // dst_ref_count*(F-1)
                assert(c.m_refs_created == 10 * 3);     // dst_ref_count*F
                assert(c.m_physical_freed == 5 * 4096); // uncompressed: good_blocks*bs
@@ -1004,7 +1007,7 @@ test_debt_fields_shared_builder()
        }
        // F=0 edge (no ops): refs_added guarded against wrap, refs_created 0.
        {
-               const auto c = scan_next_debt_fields(999, 0, 0, false, 0, 0, 4096);
+               const auto c = scan_next_debt_fields(999, 0, 0, false, 0, 0, 4096, 0);
                assert(c.m_refs_added == 0);
                assert(c.m_refs_created == 0);
        }
@@ -1017,7 +1020,21 @@ test_debt_fields_shared_builder()
                const auto in = make_inputs(1, { {0, 1} }, /*ref=*/3000);
                const auto c = scan_next_debt_fields(3000, /*F=*/1, /*copy=*/0,
                        /*compressed=*/false, /*phys=*/0, /*good_blocks=*/1,
-                       /*clone_alignment=*/BLOCK);
+                       /*clone_alignment=*/BLOCK, /*snap_extra=*/0);
+               assert(c.space_debt(policy)
+                       == scan_next_covering_debt(in, policy, 1, 0, 1));
+       }
+       // snap_extra is carried through the shared builder and priced by both
+       // paths identically (the ref-op-cost inertness lesson): a search input
+       // with m_snap_extra set must reconcile with the builder's CostReport.
+       {
+               auto policy = default_policy();
+               auto in = make_inputs(1, { {0, 1} }, /*ref=*/4);
+               in.m_snap_extra = 3;
+               const auto c = scan_next_debt_fields(4, /*F=*/1, /*copy=*/0,
+                       /*compressed=*/false, /*phys=*/0, /*good_blocks=*/1,
+                       /*clone_alignment=*/BLOCK, /*snap_extra=*/3);
+               assert(c.m_snap_extra == 3);
                assert(c.space_debt(policy)
                        == scan_next_covering_debt(in, policy, 1, 0, 1));
        }
@@ -1067,12 +1084,12 @@ test_region_block_count_eof_tail()
        const auto fixed = scan_next_debt_fields(
                /*dst_ref_count=*/2, /*frag_count=*/1, /*copy_ops=*/1,
                /*dst_compressed=*/false, /*dst_phys_size=*/0,
-               good_blocks, bs);
+               good_blocks, bs, /*snap_extra=*/0);
        assert(fixed.m_physical_freed == 0);
        assert(fixed.space_debt(policy) > 0.0);   // do-nothing (0.0) now wins
 
        // The pre-fix miscount reproduces the exact -3787 seen in production.
-       const auto buggy = scan_next_debt_fields(2, 1, 1, false, 0, /*good=*/1, bs);
+       const auto buggy = scan_next_debt_fields(2, 1, 1, false, 0, /*good=*/1, bs, 0);
        assert(buggy.space_debt(policy) == -3787.0);
 }
 
@@ -1222,6 +1239,52 @@ test_snap_extra_grouping()
        }) == 2);
 }
 
+static void
+test_snapshot_sharing_term()
+{
+       // The design's worked example: whole-extent dedupe (F=1), compressed
+       // 128K->4K dst, 10 refs in one k=10 family group (snap_extra = 9).
+       // base debt = -1*53 - 4096 = -4149; snap term = 9 * 53 * sharing.
+       const auto fields = [](uint64_t snap_extra) {
+               return scan_next_debt_fields(
+                       /*dst_ref_count=*/10, /*frag_count=*/1, /*copy_ops=*/0,
+                       /*dst_compressed=*/true, /*dst_phys_size=*/4096,
+                       /*good_blocks=*/32, /*clone_alignment=*/4096,
+                       snap_extra);
+       };
+
+       // Default sharing 1.0: -4149 + 477 = -3672, still a win.
+       {
+               const auto policy = default_policy();
+               assert(fields(9).space_debt(policy) == -3672.0);
+       }
+
+       // sharing = 0 turns the term off: back to the base debt even with
+       // snap_extra set.
+       {
+               auto policy = default_policy();
+               policy.m_snapshot_sharing = 0.0;
+               assert(fields(9).space_debt(policy) == -4149.0);
+       }
+
+       // sharing = 10 (expensive metadata): -4149 + 4770 = +621 — the
+       // do-nothing floor (0.0) wins.
+       {
+               auto policy = default_policy();
+               policy.m_snapshot_sharing = 10.0;
+               const auto debt = fields(9).space_debt(policy);
+               assert(debt == 621.0);
+               assert(!(debt < 0.0));
+       }
+
+       // No family refs: the term contributes nothing at any sharing value.
+       {
+               auto policy = default_policy();
+               policy.m_snapshot_sharing = 10.0;
+               assert(fields(0).space_debt(policy) == -4149.0);
+       }
+}
+
 int
 main(int, char **)
 {
@@ -1252,5 +1315,6 @@ main(int, char **)
        RUN_A_TEST(test_search_prefers_high_ref_src());
        RUN_A_TEST(test_snap_cluster_map());
        RUN_A_TEST(test_snap_extra_grouping());
+       RUN_A_TEST(test_snapshot_sharing_term());
        return 0;
 }