]> git.hungrycats.org Git - bees/commitdiff
plan: enforce the src once-only rule inside the covering search
authorZygo Blaxell <bees@furryterror.org>
Wed, 2 Sep 2026 03:58:55 +0000 (23:58 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:15 +0000 (00:04 -0400)
Teach the covering search the no-reuse-src-regions rule instead of
throwing after plan selection:

- PlanSearchMatch carries the src identity (bytenr, or node address
  with the top bit set for synthetic extents) and src interval.
- The exact DP refuses a dedupe edge whose src slice overlaps a slice
  already consumed along the path on the same src key
  (plan_src_edge_blocked counts refusals).  The check walks the
  state's back-trace, O(path) per tentative edge.  Dominance pruning
  still ignores src usage, so this is an admission heuristic, not an
  exact src-aware search: a pruned state could in principle have
  avoided a later conflict.
- The greedy fallback keeps a live set of absorbed matches and picks
  the farthest-reaching one whose src slice is still free, instead of
  the src-blind running argmax.

Because the search admits src-overlapping matches whose used slices
are disjoint, the whole-interval pre-compose check would false-fire;
the once-only trap moves into scan_next_compose_plan_tree, checking
the src slice of each emitted dedupe child (the exact bytes sent to
the kernel) and throwing on overlap.

This supersedes the whole-interval throw-and-skip added by
0913df4a45 "scan-next: enforce the src once-only rule at plan
composition" (removed here).  A three-way comparison on
the same corpus showed the throw-and-skip variant discarding 640849
otherwise-buildable plans and freeing 28% less space at equal IO than
the pre-enforcement baseline, while enforcing inside the search freed
20% more space at equal IO than that baseline and finished the run
fastest of all variants (77.6M edges refused, only 259 residual
compose-trap throws from DP-slice vs compose-emission mismatch on
overlapping dst matches - a follow-up refinement).

Unit-test harness: make_inputs gives each synthetic match a distinct
src key with a src interval mirroring its dst interval, so existing
single-src-per-match scenarios are unaffected by the new check.

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

index 7d9b2b4ab1a3f311ff6d4b5cf0cd4c965c03da26..8859882c07accffa2d3d9c4b138baa30bc52690c 100644 (file)
@@ -273,14 +273,30 @@ scan_next_plan_greedy(const PlanSearchInputs &in,
        size_t copy_ops   = 0;
        size_t matched_blocks = 0;
 
+       // Once-only: src slices consumed by emitted dedupe segments, per
+       // src key, across the whole dst plan.
+       map<uint64_t, vector<pair<uint64_t, uint64_t>>> used_src;
+       const auto src_slice_free = [&](const PlanSearchMatch &m,
+               uint64_t seg_begin, uint64_t seg_end) -> bool {
+               const uint64_t sa = m.m_src_begin + (seg_begin - m.m_dst_begin);
+               const uint64_t sb = sa + (seg_end - seg_begin);
+               const auto it = used_src.find(m.m_src_key);
+               if (it == used_src.end()) return true;
+               for (const auto &iv : it->second) {
+                       if (sa < iv.second && iv.first < sb) return false;
+               }
+               return true;
+       };
        for (const auto &region : in.m_data_regions) {
                uint64_t pos      = region.m_begin;
                size_t   mp       = 0;                    // sweep cursor into by_begin
-               uint64_t best_end = region.m_begin;       // running max clamped reach
-               size_t   best_idx = in.m_matches.size();
+               vector<size_t> live;                      // absorbed, dst_begin <= pos
                while (pos < region.m_end) {
-                       // Absorb matches now eligible (dst_begin <= pos); keep the running
-                       // argmax of clamped dst_end.  Monotone across the region -> O(M+V).
+                       // Absorb matches now eligible (dst_begin <= pos) into the
+                       // live set.  Unlike the src-blind greedy's running argmax,
+                       // the once-only check depends on pos, so the best live
+                       // match is rescanned per step (O(M) worst case — this is
+                       // the over-budget fallback path already).
                        while (mp < by_begin.size()
                               && in.m_matches[by_begin[mp]].m_dst_begin <= pos) {
                                const size_t   idx = by_begin[mp];
@@ -288,11 +304,23 @@ scan_next_plan_greedy(const PlanSearchInputs &in,
                                ++mp;
                                if (m.m_dst_end <= region.m_begin
                                    || m.m_dst_begin >= region.m_end) continue;
+                               live.push_back(idx);
+                       }
+                       // Pick the live match with the farthest clamped reach whose
+                       // src slice for [pos, reach) is still free; equal reach
+                       // breaks toward the higher-ref src (see the DP reach
+                       // precompute).
+                       uint64_t best_end = pos;
+                       size_t   best_idx = in.m_matches.size();
+                       for (const size_t idx : live) {
+                               const auto &m = in.m_matches[idx];
+                               if (m.m_dst_end <= pos) continue;
                                const uint64_t end = min(m.m_dst_end, region.m_end);
-                               // Farther reach wins; equal reach breaks toward the
-                               // higher-ref src (see the DP reach precompute) so a
-                               // whole-extent cover consolidates onto the canonical
-                               // extent.  Src choice does not change the edge's debt.
+                               if (end <= pos) continue;
+                               if (!src_slice_free(m, pos, end)) {
+                                       BEESCOUNT(plan_src_edge_blocked);
+                                       continue;
+                               }
                                if (end > best_end
                                    || (end == best_end
                                        && best_idx != in.m_matches.size()
@@ -304,6 +332,10 @@ scan_next_plan_greedy(const PlanSearchInputs &in,
                        }
                        if (best_end > pos) {
                                // Dedupe edge [pos, best_end) served by match best_idx.
+                               const auto &m = in.m_matches[best_idx];
+                               const uint64_t sa = m.m_src_begin + (pos - m.m_dst_begin);
+                               used_src[m.m_src_key].emplace_back(
+                                       sa, sa + (best_end - pos));
                                result.m_selected_matches.push_back(best_idx);
                                matched_blocks += block_count(pos, best_end);
                                ++dedupe_ops;
@@ -423,6 +455,10 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                        size_t   m_match_idx = 0;       // when m_dedupe
                        uint64_t m_copy_begin = 0;      // when !m_dedupe
                        uint64_t m_copy_end   = 0;
+                       // Once-only: the src slice this dedupe edge consumes.
+                       uint64_t m_src_key   = 0;       // when m_dedupe
+                       uint64_t m_src_begin = 0;
+                       uint64_t m_src_end   = 0;
                };
                // Persistent (immutable) singly-linked list, newest edge first.
                // Appending an edge conses one node that shares the predecessor's
@@ -750,6 +786,7 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                                // check it once, before the inner loop.
                                if (cover_idx != in.m_matches.size()
                                    && within_limits(st.m_dedupe_ops + 1, st.m_copy_ops)) {
+                                       const auto &cm = in.m_matches[cover_idx];
                                        for (size_t vj = vi + 1;
                                             vj < nverts && verts[vj] <= reach; ++vj) {
                                                const uint64_t b   = verts[vj];
@@ -760,13 +797,40 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                                                        st.m_dedupe_ops + 1, st.m_copy_ops, nd)) {
                                                        continue;
                                                }
+                                               // Once-only: refuse the edge if its src slice
+                                               // overlaps a slice already consumed along this
+                                               // path on the same src.  O(path) per tentative
+                                               // edge; dominance pruning above keeps paths
+                                               // short.  Pruning still ignores src usage, so
+                                               // this is an admission heuristic, not an exact
+                                               // src-aware search.
+                                               const uint64_t sa = cm.m_src_begin
+                                                       + (a - cm.m_dst_begin);
+                                               const uint64_t sb = sa + (b - a);
+                                               bool src_reused = false;
+                                               for (const State::EdgeNode *n = st.m_edges.get();
+                                                    n; n = n->m_prev.get()) {
+                                                       const auto &e = n->m_edge;
+                                                       if (e.m_dedupe
+                                                           && e.m_src_key == cm.m_src_key
+                                                           && sa < e.m_src_end
+                                                           && e.m_src_begin < sb) {
+                                                               src_reused = true;
+                                                               break;
+                                                       }
+                                               }
+                                               if (src_reused) {
+                                                       BEESCOUNT(plan_src_edge_blocked);
+                                                       continue;
+                                               }
                                                State ns = st;
                                                ns.m_dedupe_ops     += 1;
                                                ns.m_matched_blocks += blk;
                                                ns.m_partial_debt    = nd;
                                                ns.m_edges = make_shared<const State::EdgeNode>(
                                                        State::EdgeNode{
-                                                               State::Edge{ true, cover_idx, 0, 0 },
+                                                               State::Edge{ true, cover_idx, 0, 0,
+                                                                       cm.m_src_key, sa, sb },
                                                                st.m_edges });
                                                add_state(at[vj], std::move(ns));
                                        }
index 043f536c7d7ff7954f8ebde18172c2ff360ae06c..634b5135554f1629830b86e8bdc6fad160c3dc74 100644 (file)
@@ -181,6 +181,12 @@ struct PlanSearchMatch {
        uint64_t m_dst_end        = 0;
        size_t   m_group_id       = 0;  ///< caller-defined match identity
        uint64_t m_src_ref_count  = 0;  ///< src refs()->size(), tie-break only
+       /// Src identity + interval for the once-only rule: the search
+       /// refuses a dedupe edge whose src slice overlaps a src slice
+       /// already used on the same src key along the path.
+       uint64_t m_src_key        = 0;
+       uint64_t m_src_begin      = 0;
+       uint64_t m_src_end        = 0;
 };
 
 /// Fixed per-dst inputs to the search, mirroring scan_next_plan_init's
index c4af262e4743442170bf014cada2e8d564601e42..a8a44af2976dccf532c36a1daa33e216744228ee 100644 (file)
@@ -1170,8 +1170,17 @@ scan_next_choose_match_plan(const BeesExtent &dst,
                        // src extents here were already resolved to produce these matches.
                        const uint64_t src_refs = rewrite_policy.m_prefer_canonical_src
                                ? match.m_src.refs(layer)->size() : 0;
+                       // Src identity for the search's once-only check: bytenr
+                       // when the src has one; otherwise the node address with
+                       // the top bit set, keeping the two keyspaces disjoint.
+                       const auto src_bytenr = match.m_src.bytenr_opt();
+                       const uint64_t src_key = src_bytenr ? *src_bytenr
+                               : (reinterpret_cast<uintptr_t>(
+                                       match.m_src.extent_sp().get())
+                                       | (uint64_t(1) << 63));
                        in.m_matches.push_back(PlanSearchMatch{
-                               match.m_dst_begin, match.m_dst_end, flat.size(), src_refs });
+                               match.m_dst_begin, match.m_dst_end, flat.size(), src_refs,
+                               src_key, match.m_src_begin, match.m_src_end });
                        flat.push_back(FlatMatch{ &group, &match });
                }
        }
@@ -1252,6 +1261,10 @@ scan_next_compose_plan_tree(
 {
        BeesSuperExtentBuilder builder;
        size_t match_idx = 0;
+       // Once-only enforcement on what is actually emitted: each dedupe
+       // child's src slice, per src extent, must not overlap a slice this
+       // plan already emitted.
+       map<pair<uint64_t, const void *>, vector<pair<uint64_t, uint64_t>>> used_src;
 
        const auto dst_bm = dst.full_block_map(&Borrower::current().layer());
        for (const auto &region : dst_bm->m_regions) {
@@ -1286,6 +1299,32 @@ scan_next_compose_plan_tree(
                                const auto sub_begin = m.m_src_begin + src_offset;
                                const auto src_sub = BeesExtent::subextent(m.m_src,
                                        sub_begin, sub_begin + match_len);
+                               const auto src_bytenr = m.m_src.bytenr_opt();
+                               const auto src_key = src_bytenr
+                                       ? make_pair(*src_bytenr,
+                                               static_cast<const void *>(nullptr))
+                                       : make_pair(uint64_t(0),
+                                               static_cast<const void *>(
+                                                       m.m_src.extent_sp().get()));
+                               auto &ivals = used_src[src_key];
+                               for (const auto &iv : ivals) {
+                                       if (sub_begin < iv.second
+                                           && iv.first < sub_begin + match_len) {
+                                               BEESCOUNT(plan_src_overlap);
+                                               BEESLOGERR("once-only rule violation at compose: dst "
+                                                       << to_hex(dst.bytenr())
+                                                       << " src " << m.m_src
+                                                       << " slice [" << to_hex(sub_begin)
+                                                       << ".." << to_hex(sub_begin + match_len)
+                                                       << ") overlaps emitted ["
+                                                       << to_hex(iv.first) << ".."
+                                                       << to_hex(iv.second) << ")");
+                                               THROW_ERROR(runtime_error,
+                                                       "once-only rule violation at compose: dst "
+                                                       << to_hex(dst.bytenr()));
+                                       }
+                               }
+                               ivals.emplace_back(sub_begin, sub_begin + match_len);
                                const auto child = bees_make_dedupeextent(src_sub);
                                builder.append(BeesExtentSlice(child, 0, match_len));
                                pos = match_end;
@@ -1858,61 +1897,6 @@ struct ExtentPlan {
        PlanCost m_total_cost;
 };
 
-/// Enforce the once-only rule at plan composition: each src block may
-/// be referenced at most once across the dedupe edges of a single dst
-/// plan.  Overlapping src references within one plan stack multiple
-/// new refs onto the same physical blocks, which explodes reference
-/// counts on extremely common data; every observed violation has also
-/// been a planner bug pairing a dst with data the src never had
-/// (match transposition onto a non-identical src).  Throws to reject
-/// the plan so it can never reach the kernel; callers log the
-/// exception and skip the extent.
-static void
-scan_next_enforce_src_once_only(
-       const BeesExtent &dst,
-       const vector<ExtentMatch> &matches)
-{
-       BEESTRACE("scan_next_enforce_src_once_only dst " << to_hex(dst.bytenr()));
-       // Key src extents by bytenr when they have one, node identity
-       // otherwise (synthetic extents), then check the src intervals
-       // under each key for pairwise overlap.
-       map<pair<uint64_t, const void *>, vector<pair<uint64_t, uint64_t>>> src_ranges;
-       for (const auto &m : matches) {
-               const auto bytenr = m.m_src.bytenr_opt();
-               const auto key = bytenr
-                       ? make_pair(*bytenr, static_cast<const void *>(nullptr))
-                       : make_pair(uint64_t(0),
-                               static_cast<const void *>(m.m_src.extent_sp().get()));
-               src_ranges[key].emplace_back(m.m_src_begin, m.m_src_end);
-       }
-       for (auto &p : src_ranges) {
-               auto &ranges = p.second;
-               sort(ranges.begin(), ranges.end());
-               for (size_t i = 1; i < ranges.size(); ++i) {
-                       if (ranges[i].first >= ranges[i - 1].second) {
-                               continue;
-                       }
-                       BEESCOUNT(plan_src_overlap);
-                       BEESLOGERR("once-only rule violation: dst "
-                               << to_hex(dst.bytenr()) << " plan references src blocks more than once");
-                       for (const auto &m : matches) {
-                               BEESLOGERR("  match dst [" << to_hex(m.m_dst_begin)
-                                       << ".." << to_hex(m.m_dst_end)
-                                       << ") src [" << to_hex(m.m_src_begin)
-                                       << ".." << to_hex(m.m_src_end)
-                                       << ") src extent " << m.m_src);
-                       }
-                       THROW_ERROR(runtime_error,
-                               "once-only rule violation: dst " << to_hex(dst.bytenr())
-                               << " src ranges [" << to_hex(ranges[i - 1].first)
-                               << ".." << to_hex(ranges[i - 1].second)
-                               << ") and [" << to_hex(ranges[i].first)
-                               << ".." << to_hex(ranges[i].second)
-                               << ") overlap");
-               }
-       }
-}
-
 /// Try to build a DstPlan for one dst extent given candidate sources.
 /// Returns nullopt if the plan is not viable (no matches, cost too
 /// high, or choose_match_plan rejects).
@@ -1938,8 +1922,11 @@ scan_next_try_build_dst_plan(
                [](const auto &lhs, const auto &rhs) {
                        return lhs.m_dst_begin < rhs.m_dst_begin;
                });
-       scan_next_enforce_src_once_only(dst, chosen->m_selected_matches);
-
+       // The once-only rule is enforced on the emitted dedupe slices inside
+       // scan_next_compose_plan_tree: the search admits src-overlapping
+       // *matches* so long as the *slices* a covering uses are disjoint, so
+       // a whole-interval check on the selected matches would false-fire
+       // here.
        const auto plan_tree = scan_next_compose_plan_tree(
                dst, chosen->m_selected_matches);
 
index 285c2a358b0b1627f801b96ad04fc03aa44ae55d..bacbd98064c76a0d43071afb4048e15dcfece956 100644 (file)
@@ -398,10 +398,17 @@ make_inputs(uint64_t n_blocks,
        PlanSearchInputs in;
        in.m_data_regions.push_back(PlanSearchRegion{ 0, n_blocks * BLOCK });
        for (size_t i = 0; i < match_blocks.size(); ++i) {
+               // Each match models a distinct src extent (distinct src_key),
+               // with a src interval mirroring its dst interval, so the
+               // search's once-only src check never fires within these
+               // single-src-per-match scenarios.
                in.m_matches.push_back(PlanSearchMatch{
                        match_blocks[i].first * BLOCK,
                        match_blocks[i].second * BLOCK,
-                       i });
+                       i, 0,
+                       /*src_key*/ 1000 + i,
+                       match_blocks[i].first * BLOCK,
+                       match_blocks[i].second * BLOCK });
        }
        in.m_dst_ref_count = ref_count;
        in.m_dst_compressed = false;