]> git.hungrycats.org Git - bees/commitdiff
scan_next: wire refs_created into the plan-cost path so ref-op-cost applies
authorZygo Blaxell <bees@furryterror.org>
Wed, 8 Jul 2026 19:40:47 +0000 (15:40 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:13 +0000 (00:04 -0400)
ref-op-cost weights CostReport::m_refs_created, but that field was only ever
set by scan_next_covering_debt (bees-plan.cc), on a throwaway CostReport used
for the shortest-path search's internal covering order.  The plan-level cost
that drives orientation selection is built from scan_next_dst_debt, whose
ScanNextDstDebt struct never carried m_refs_created — so every plan reaching
min_element/cheaper_than had m_refs_created == 0, and the refs_created *
ref_op_cost term of space_debt() was always zero.  ref-op-cost has been inert
in the runtime planner since it was added.

The consequence is a re-canonicalization blow-up: for a fresh start matching a
high-ref candidate, Plan B (drain the fat candidate as dst) scores
refs_added = dst_ref_count*(F-1) = 0 for a whole-extent (F=1) dedupe,
refs_created = dst_ref_count*F uncharged, extents_delta = -1, physical_freed
positive — a strongly negative debt, so draining a 9650-ref extent looks free
and beats both Plan A (accumulate) and do-nothing.  Observed in the t10 stress
run as extents accumulating to ~9650 as src then immediately being drained as
dst, the same cluster relaying down a chain of identical extents.

Add m_refs_created to ScanNextDstDebt and compute it as dst_ref_count *
frag_count (unguarded, matching scan_next_covering_debt), then propagate it
into Plan A's cost and Plan B's per-entry and aggregate costs.  With the term
live, draining a 9650-ref dst costs ~9650*ref_op_cost of debt and loses to
accumulation, so a canonical extent grows until it reaches refs-max and
retires instead of relaying its cluster.  The ScanNextDstDebt doc note now
states the mirror-scan_next_covering_debt invariant this violated.

Assisted-by: Claude-Code:claude-opus-4-8
src/bees-scan-next.cc

index 38e4b060e75ce80c8ff4bf2983e91bfa2b1f3205..e4a77304f2297d8d88839432d78f800a539b7eef 100644 (file)
@@ -1873,10 +1873,15 @@ scan_next_compute_match_views(const BeesExtent &start,
 
 /// Per-dst raw inputs for the do-nothing space-debt model
 /// (do-nothing-plan.md §4).  Aggregated across all dsts of a plan into
-/// CostReport's m_refs_added / m_extents_delta / m_physical_freed, then
-/// combined by CostReport::space_debt() into the m_space_debt ordering key.
+/// CostReport's m_refs_added / m_refs_created / m_extents_delta /
+/// m_physical_freed, then combined by CostReport::space_debt() into the
+/// m_space_debt ordering key.  Must mirror scan_next_covering_debt
+/// (bees-plan.cc) field-for-field — the search and the plan-cost path share
+/// one space_debt() formula, so a term set in one but not the other (as
+/// m_refs_created once was) silently drops out of orientation selection.
 struct ScanNextDstDebt {
        uint64_t m_refs_added     = 0;
+       uint64_t m_refs_created   = 0;
        int64_t  m_extents_delta  = 0;
        uint64_t m_physical_freed = 0;
 };
@@ -1916,6 +1921,17 @@ scan_next_dst_debt(BeesExtentLayer &layer,
                        * (static_cast<uint64_t>(frag_count) - 1);
        }
 
+       // refs_created = dst_ref_count * F: every dst reference is re-written once
+       // per fragment, the actual FILE_EXTENT_SAME work.  Unlike refs_added (net
+       // metadata growth, F-1 per ref) this is charged even for a whole-extent
+       // (F == 1) dedupe, so it is NOT guarded by frag_count > 1 — mirrors
+       // scan_next_covering_debt (bees-plan.cc).  This is the term ref-op-cost
+       // weights; without it the plan-level cost that drives orientation
+       // selection never prices the references a dedupe moves, so draining a
+       // high-ref extent as dst looks free.
+       rv.m_refs_created = static_cast<uint64_t>(dst_ref_count)
+               * static_cast<uint64_t>(frag_count);
+
        // physical_freed: a compressed dst is removed wholesale (the
        // compressed-dst-with-copy reject forbids retaining any fragment),
        // so the recovered space is exactly its phys_size.  An uncompressed
@@ -2191,6 +2207,7 @@ BeesStartAsDstPlan::ensure_selection()
                const auto debt = scan_next_dst_debt(
                        Borrower::current().layer(), 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;
        }
@@ -2464,6 +2481,7 @@ BeesStartAsSrcPlan::process_candidate(
                const auto debt = scan_next_dst_debt(
                        Borrower::current().layer(), 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;
        }
@@ -2477,6 +2495,7 @@ BeesStartAsSrcPlan::process_candidate(
                m_cost.m_copy_bytes  += e.m_entry_cost.m_copy_bytes;
                m_cost.m_plan_ops    += e.m_entry_cost.m_plan_ops;
                m_cost.m_refs_added     += e.m_entry_cost.m_refs_added;
+               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;
        }