]> git.hungrycats.org Git - bees/commitdiff
scan_next: pivot Plan B onto the highest-ref whole-extent duplicate
authorZygo Blaxell <bees@furryterror.org>
Thu, 9 Jul 2026 18:42:16 +0000 (14:42 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:13 +0000 (00:04 -0400)
Plan B fixes the start as the src, so scanning a fresh low-ref extent that
matches an older high-ref canonical drained the canonical onto the newcomer —
re-canonicalization — because Plan B can aggregate several destinations and
out-free single-dst Plan A even while moving the canonical's whole reference
set.  The right operation isn't a choice between the two existing orientations;
it is to re-center the dedupe star on the canonical, making the start just
another destination.

When prefer-canonical-src is set, finalize() picks the star center as the
highest-reference member of {start} ∪ whole-extent-matching candidates — a
single argmax over the accepted entries, O(N), no pairwise search.  Centering on
the max-ref member minimizes the references the plan moves (Σrefs − center_refs);
for a set of equivalent whole-extent duplicates that churn is the only cost that
differs between orientations, so the members free equal physical space and only
refs_created changes, dropping by center_refs − start_refs.  With ref-op-cost on,
the existing debt ordering then prefers the pivot automatically; the argmax is
only decisive at ref-op-cost = 0 where the churn is unpriced.

The center is byte-identical to the start over the whole extent, so at build
time each destination's match transposes onto it by swapping the src extent, and
the start's own destination is a whole-extent match.  The center is a real
dedupe src (m_src_bytenrs push-to-front hash refresh, like a Plan A src); the
start does not survive.  Limited to whole-extent centers, where the star is a
clean set of interchangeable duplicates; partial matches keep the ordinary
orientation.  Off unless prefer-canonical-src is set.

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

index 62d4d21b504c3b1bf696368c389a7d3ecdc8f1c4..d77a32023b473ff794f99314cf8127ea6866f318 100644 (file)
@@ -459,6 +459,11 @@ public:
        std::list<BeesExtent> srcs() const override;
        std::ostream &print(std::ostream &os) const override;
 
+       /// Re-center the dedupe star on the highest-reference whole-extent
+       /// duplicate when prefer-canonical-src is set (see the definition); a no-op
+       /// otherwise.  Runs once after the candidate loop, before selection.
+       void finalize() override;
+
        /// Opaque accessor for the executor bridge.
        const Impl *impl() const { return m_impl.get(); }
 
index 38cd1a0e194632d262415073ee20eb2e038261ae..32113389c3346d983ebe23d71ab4cf87663d2675 100644 (file)
@@ -2366,8 +2366,18 @@ struct BeesStartAsSrcPlan::Impl {
                BeesExtent              m_candidate;
                std::vector<ExtentMatch> m_matches;
                CostReport              m_entry_cost;
+               /// This candidate is a whole-extent duplicate of the start (one dedupe
+               /// op, no copy/hole), so it could replace the start as the star's
+               /// center — see finalize().
+               bool                    m_whole_extent = false;
        };
        std::deque<Entry> m_entries;
+       /// Set by finalize() (prefer-canonical-src) when a whole-extent candidate
+       /// outranks the start on reference count: the plan re-centers the dedupe
+       /// star on that candidate, the start joins the destinations, and every
+       /// destination dedupes onto this center instead of the start.  Empty means
+       /// the start is the center (the ordinary Plan B).
+       std::optional<BeesExtent> m_pivot_center;
 };
 
 BeesStartAsSrcPlan::BeesStartAsSrcPlan(const BeesExtent &start,
@@ -2442,6 +2452,11 @@ BeesStartAsSrcPlan::process_candidate(
        Impl::Entry entry;
        entry.m_candidate = candidate;
        entry.m_matches = matches->m_candidate_as_dst;
+       // A single dedupe op covering the whole extent (no copy or hole) means the
+       // candidate is a whole-extent duplicate of the start — eligible to become
+       // the star's center in finalize() (prefer-canonical-src).
+       entry.m_whole_extent =
+               chosen->rewrite_ops() == 1 && chosen->dedupe_ops() == 1;
        entry.m_entry_cost.m_bytes_freed =
                static_cast<uint64_t>(chosen->good_blocks())
                * static_cast<uint64_t>(BLOCK_SIZE_SUMS);
@@ -2515,12 +2530,67 @@ BeesStartAsSrcPlan::process_candidate(
        // continues(), so dropping the stop does not affect termination.
 }
 
+void
+BeesStartAsSrcPlan::finalize()
+{
+       // prefer-canonical-src: if a whole-extent-duplicate candidate holds more
+       // references than the start, re-center the dedupe star on it.  Centering on
+       // the highest-reference member minimizes the references the plan moves
+       // (every other member's references move onto the center), and for a set of
+       // equivalent whole-extent duplicates that reference churn is the only cost
+       // that differs between orientations.  The center is a single argmax over the
+       // accepted entries — O(N), no pairwise search.
+       if (!m_policy.m_prefer_canonical_src || m_impl->m_entries.empty()) {
+               return;
+       }
+       auto &layer = Borrower::current().layer();
+       const uint64_t start_refs = m_start.refs(layer)->size();
+       const Impl::Entry *center = nullptr;
+       uint64_t center_refs = start_refs;
+       for (const auto &e : m_impl->m_entries) {
+               if (!e.m_whole_extent) {
+                       continue;
+               }
+               // Whole-extent entry => F == 1 => m_refs_created is the candidate's
+               // own reference count.
+               const uint64_t refs = e.m_entry_cost.m_refs_created;
+               if (refs > center_refs) {
+                       center_refs = refs;
+                       center = &e;
+               }
+       }
+       if (!center) {
+               // The start already holds the most references — ordinary Plan B.
+               return;
+       }
+       m_impl->m_pivot_center = center->m_candidate;
+       // Re-centering swaps the start and the center between the dst set and the
+       // src role.  The members are whole-extent duplicates, so they free equal
+       // 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.
+       m_cost.m_refs_created = m_cost.m_refs_created - center_refs + start_refs;
+       m_cost.m_space_debt = m_cost.space_debt(m_policy);
+}
+
 list<BeesExtent>
 BeesStartAsSrcPlan::dsts() const
 {
        list<BeesExtent> rv;
-       for (const auto &e : m_impl->m_entries) {
-               rv.push_back(e.m_candidate);
+       if (m_impl->m_pivot_center) {
+               // Re-centered: the start is now a dst, the center is the src.
+               const auto center_bytenr = m_impl->m_pivot_center->bytenr();
+               rv.push_back(m_start);
+               for (const auto &e : m_impl->m_entries) {
+                       if (e.m_candidate.bytenr() == center_bytenr) {
+                               continue;
+                       }
+                       rv.push_back(e.m_candidate);
+               }
+       } else {
+               for (const auto &e : m_impl->m_entries) {
+                       rv.push_back(e.m_candidate);
+               }
        }
        return rv;
 }
@@ -2528,11 +2598,16 @@ BeesStartAsSrcPlan::dsts() const
 list<BeesExtent>
 BeesStartAsSrcPlan::srcs() const
 {
-       // The start extent is the sole src; return it only when the
-       // plan has accepted at least one dst.
+       // Return the src only when the plan has accepted at least one dst.  The src
+       // is the start, unless finalize() re-centered the star on a higher-ref
+       // whole-extent candidate (prefer-canonical-src), in which case the start is
+       // a dst and that candidate is the sole src.
        if (m_impl->m_entries.empty()) {
                return {};
        }
+       if (m_impl->m_pivot_center) {
+               return { *m_impl->m_pivot_center };
+       }
        return { m_start };
 }
 
@@ -3199,6 +3274,66 @@ scan_next_build_extent_plan(
                }
                const auto &start = pb->start();
 
+               // prefer-canonical-src re-centered the star on a higher-ref whole-extent
+               // duplicate (finalize()): the center is the sole src; the start and the
+               // remaining candidates are the dsts.  The center is byte-identical to
+               // the start over the whole extent, so a dst's match against the start
+               // transposes onto the center by swapping the src extent, and the start's
+               // own dst is a whole-extent match.  The center is a real src (push-to-
+               // front hash refresh via m_src_bytenrs, like a Plan A src); the start
+               // does not survive.
+               if (impl->m_pivot_center) {
+                       const auto &center = *impl->m_pivot_center;
+                       const auto center_bytenr = center.bytenr();
+                       out.m_src_bytenrs.insert(center_bytenr);
+                       out.m_start_survived = false;
+                       const auto build_onto_center = [&](const BeesExtent &dst,
+                               vector<ExtentMatch> matches) {
+                               for (auto &m : matches) {
+                                       m.m_src = center;
+                               }
+                               vector<CandidateGroup> sources;
+                               CandidateGroup g;
+                               g.m_extent = center;
+                               g.m_matches = std::move(matches);
+                               sources.push_back(std::move(g));
+                               const bool force = filter_cache.force_rewrite(dst.bytenr());
+                               try {
+                                       auto dp = scan_next_try_build_dst_plan(ctx, dst,
+                                               sources, policy, force);
+                                       if (dp) {
+                                               dp->m_space_debt       = winner_space_debt;
+                                               dp->m_sel_refs_created = winner_refs_created;
+                                               dp->m_dst_ref_count = dst.refs(diag_layer)->size();
+                                               out.m_total_cost += dp->m_cost;
+                                               out.m_dst_plans.push_back(std::move(*dp));
+                                       }
+                               } catch (const Borrower::Dead &) {
+                                       throw;
+                               } catch (const exception &e) {
+                                       scan_next_log_skip_extent("plan-b-pivot-build", dst, e);
+                               }
+                       };
+                       // The start, deduped whole-extent onto the center.
+                       const auto start_ls = start.logical_size(diag_layer);
+                       ExtentMatch sm;
+                       sm.m_dst = start;
+                       sm.m_dst_begin = 0;
+                       sm.m_dst_end = start_ls;
+                       sm.m_src = center;
+                       sm.m_src_begin = 0;
+                       sm.m_src_end = start_ls;
+                       build_onto_center(start, { sm });
+                       // The remaining candidates, transposed onto the center.
+                       for (const auto &entry : impl->m_entries) {
+                               if (entry.m_candidate.bytenr() == center_bytenr) {
+                                       continue;
+                               }
+                               build_onto_center(entry.m_candidate, entry.m_matches);
+                       }
+                       return out;
+               }
+
                for (const auto &entry : impl->m_entries) {
                        vector<CandidateGroup> sources;
                        CandidateGroup g;