]> git.hungrycats.org Git - bees/commitdiff
bees-plan: let force-rewrite rewrite compressed extents via copy
authorZygo Blaxell <bees@furryterror.org>
Thu, 16 Jul 2026 17:14:15 +0000 (13:14 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:14 +0000 (00:04 -0400)
A force-marked compressed extent with no dedupe cover crashed the
planner with a per-extent exception:

    plans.size() = 0 failed constraint check (!plans.empty())
    at bees-scan-next.cc:3778

The do-nothing null floor is suppressed for a force-marked start
(forced_floor), on the assumption that the forced FullRewrite Plan A is
unconditionally viable and always survives the viability filter.  That
assumption is false for a compressed dst: the coverage search forbids
all copy edges on a compressed extent (copy_front_path, the greedy
fallback, and the build-time compressed-dst-with-copy reject all enforce
"no copy on compressed").  A rewrite of a compressed extent that no
dedupe match covers needs a copy, so the selector returns no covering,
Plan A reports !viable(), the ballot empties, and the invariant throws.

This is exactly the documented zlib-to-zstd re-encode use case
(force-rewrite = yes with a compression condition), which the code never
actually delivered for compressed extents.

Lift the compressed-dst copy prohibition when the extent is force-marked.
The prohibition exists to stop compressed extents from being fragmented
by partial copies; force-rewrite is a per-filter opt-in, so the operator
has scoped exactly which extents must be rewritten and accepts the
fragmentation cost.  The numeric [rewrite] op limits still bind: a forced
rewrite is still prevented when no covering fits within copy-max /
total-max, and the operator caps fragmentation with copy-max (= 1 for
whole-extent-only rewrites).

Thread the flag through PlanSearchInputs (covers the exact DP and the
greedy fallback) and re-add the force_rewrite parameter to
scan_next_reject_reason for the build-time gate.  When op limits leave no
viable forced plan, fall back to the do-nothing floor instead of throwing
(new counter plan_force_rewrite_unrealizable), so force-rewrite stays
best-effort rather than aborting the scan.

Assisted-by: Claude-Code:claude-opus-4-8
docs/event-counters.md
src/bees-plan.cc
src/bees-plan.h
src/bees-scan-next.cc
test/test-bees-plan.cc

index ba051044eecb5f6c75161c349e656b9aabe0291b..3808804114be9076081313235e51919f64f0ef9b 100644 (file)
@@ -316,7 +316,7 @@ The `plan` event group consists of events within the `scan_next` planner: choosi
  * `plan_copy_bytes`: Total bytes copied to a tempfile across executed dst plans (unique data that had to be rewritten rather than deduped).
  * `plan_cover_exact`: The coverage search ran the exact O(V²) shortest-path DP (boundary-vertex count V within `rewrite.plan-max-vertices`).
  * `plan_cover_greedy`: The coverage search ran the linear-time greedy fallback because V exceeded `rewrite.plan-max-vertices`.
- * `plan_cover_none_compressed`: The coverage search produced no plan because a compressed dst had an interior gap no dedupe could cover (copies are forbidden on a compressed dst).
+ * `plan_cover_none_compressed`: The coverage search produced no plan because a compressed dst had an interior gap no dedupe could cover (copies are forbidden on a compressed dst unless the extent is force-marked, which lifts the prohibition).
  * `plan_cover_none_hole`: The coverage search produced no plan because the fixed hole operations alone exceeded `hole-max` or `total-max`.
  * `plan_cover_none_limit`: The coverage search produced no plan because every covering exceeded the operation limits (all paths pruned during the walk).
  * `plan_dedupe_bytes`: Total bytes matched (deduped) across executed dst plans.
@@ -324,7 +324,8 @@ The `plan` event group consists of events within the `scan_next` planner: choosi
  * `plan_empty`: The winning plan had no dst plans, so execution was a no-op (the do-nothing outcome).
  * `plan_execute`: A non-empty winning plan was executed.
  * `plan_force_rewrite`: The forced FullRewrite floor won selection for a force-marked start extent (the whole extent is rewritten regardless of dedupe benefit).
- * `plan_reject_compressed_copy`: A composed plan was rejected because it would copy part of a compressed dst (which fragments compression).
+ * `plan_force_rewrite_unrealizable`: A force-marked start extent could not be rewritten and was left as-is, because no covering fit within the rewrite op limits (`copy-max`, `total-max`, ...). force-rewrite is best-effort: the extent falls back to the do-nothing outcome instead of aborting the scan.
+ * `plan_reject_compressed_copy`: A composed plan was rejected because it would copy part of a compressed dst (which fragments compression).  Not counted for force-marked extents, whose filter has lifted the prohibition.
  * `plan_reject_compressed_dedupe`: A composed plan was rejected because it piled more than `compressed-dedupe-max` dedupe ops onto a compressed dst.
  * `plan_reject_copy_max`: A composed plan was rejected because its copy operation count exceeded `copy-max`.
  * `plan_reject_dedupe_max`: A composed plan was rejected because its dedupe operation count exceeded `dedupe-max`.
index 9fbd8bcb36310186b8a5827b1bc9851442cf62a6..cd1f35556e83a7a364183ef1ae742a1f202a4d36 100644 (file)
@@ -149,7 +149,8 @@ scan_next_plan_boundary_count(const PlanSearchInputs &in)
 // whole-extent); a copy run never spans the hole between regions.  Returns
 // nullopt when the resulting cover cannot satisfy the rewrite limits (mirroring
 // the DP's "every path pruned" outcome) or when a compressed dst has a gap no
-// dedupe covers (copy edges are not permitted on a compressed dst).  The debt
+// dedupe covers and force-rewrite is not set (copy edges are not permitted on a
+// compressed dst unless force-rewrite lifts the prohibition).  The debt
 // is computed by scan_next_covering_debt, identical to the DP's terminal
 // evaluation, so the caller's acceptable()/executor path is unchanged.
 static optional<PlanSearchResult>
@@ -211,8 +212,9 @@ scan_next_plan_greedy(const PlanSearchInputs &in,
                                ++dedupe_ops;
                                pos = best_end;
                        } else {
-                               // Gap: no match covers pos.  A compressed dst forbids copies.
-                               if (in.m_dst_compressed) {
+                               // Gap: no match covers pos.  A compressed dst forbids
+                               // copies, unless force-rewrite lifts the prohibition.
+                               if (in.m_dst_compressed && !in.m_force_rewrite) {
                                        BEESCOUNT(plan_cover_none_compressed);
                                        return nullopt;
                                }
@@ -595,9 +597,11 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                // A copy edge costs a flat +K and one op regardless of span, so the
                // lowest-debt copy landing at a vertex is always (best source at any
                // earlier vertex) + one op — one swept Pareto front of sources serves
-               // every landing, with no per-source fan-out.  Only a compressed dst,
-               // which permits no copy edges, disables it.
-               const bool copy_front_path = !in.m_dst_compressed;
+               // every landing, with no per-source fan-out.  A compressed dst permits
+               // no copy edges (all-or-nothing) — unless force-rewrite is set, which
+               // lifts the prohibition so a force-marked compressed extent can be
+               // rewritten via copy (op limits still bind; see PlanSearchInputs).
+               const bool copy_front_path = !in.m_dst_compressed || in.m_force_rewrite;
 
                for (size_t vi = 0; vi < nverts; ++vi) {
                        const uint64_t a         = verts[vi];
@@ -682,11 +686,13 @@ scan_next_plan_shortest_path(const PlanSearchInputs &in,
                // a compressed dst with uncovered data has no non-floor path.
                frontier = std::move(at.back());
                if (frontier.empty()) {
-                       // A compressed dst forbids copy edges, so an uncoverable
-                       // interior gap empties the bucket; an uncompressed dst
-                       // always gets a copy edge across a gap, so an empty bucket
-                       // here can only mean every path was pruned over the op limit.
-                       if (in.m_dst_compressed) {
+                       // A compressed dst forbids copy edges (unless force-rewrite
+                       // lifts the prohibition), so an uncoverable interior gap
+                       // empties the bucket; a dst that gets copy edges (uncompressed,
+                       // or force-marked) always covers a gap by copy, so an empty
+                       // bucket there can only mean every path was pruned over the op
+                       // limit.
+                       if (in.m_dst_compressed && !in.m_force_rewrite) {
                                BEESCOUNT(plan_cover_none_compressed);
                        } else {
                                BEESCOUNT(plan_cover_none_limit);
index 74002402160c1d7403b6cc05be518d99fffd0668..df4ac8ed915668ac84edff42ff13ac5d8ccca7cc 100644 (file)
@@ -186,9 +186,16 @@ struct PlanSearchInputs {
 
        // Per-dst debt parameters from scan_next_dst_debt.
        uint64_t m_dst_ref_count = 0;        ///< dst.refs()->size()
-       bool     m_dst_compressed = false;   ///< compressed dst: no copy edges
+       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
 
+       /// Force-rewrite (filter force-rewrite = yes) lifts the compressed-dst
+       /// copy prohibition: the operator has scoped, per filter, exactly which
+       /// extents must be rewritten and accepts the fragmentation trade-off.
+       /// The numeric op limits (copy-max, total-max, ...) still bind, so the
+       /// operator caps fragmentation with copy-max (= 1 for whole-extent-only).
+       bool     m_force_rewrite = false;
+
        // Block-size constants (both 4096 in practice; kept distinct to match
        // scan_next_plan_init's BLOCK_SIZE_SUMS block counting and
        // scan_next_dst_debt's s_clone_alignment physical credit).
index a91785c97c2adb0e778a35058308c075df9acdcd..2510ea9de318962f4a41fe5cab85ba54c393f7ef 100644 (file)
@@ -1062,6 +1062,7 @@ 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_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);
 
@@ -1279,7 +1280,7 @@ scan_next_plan_cost(BeesExtentLayer *layerp, const BeesExtent &plan)
 const char *
 scan_next_reject_reason(BeesExtentLayer *layerp, const PlanCost &cost,
        const BeesExtent &dst_tree,
-       const BeesRewritePolicy &policy)
+       const BeesRewritePolicy &policy, bool force_rewrite)
 {
        // The "no operations" and "zero benefit" status-quo rejections that
        // used to live here are gone: with the do-nothing null floor on the
@@ -1296,7 +1297,15 @@ scan_next_reject_reason(BeesExtentLayer *layerp, const PlanCost &cost,
        // grow larger, so reject any plan that copies part of a
        // compressed extent.  Dedupe and hole operations are fine —
        // only PreCopy (which splits the extent) is forbidden.
-       if (cost.m_copy_ops &&
+       //
+       // force-rewrite (filter force-rewrite = yes) lifts this prohibition:
+       // the operator has scoped, per filter, exactly which compressed
+       // extents must be rewritten and accepts the fragmentation cost.  The
+       // numeric op limits below still bind, so a forced rewrite is still
+       // prevented when no covering fits within copy-max / total-max, and the
+       // operator caps fragmentation with copy-max (= 1 for whole-extent-only
+       // rewrites).
+       if (cost.m_copy_ops && !force_rewrite &&
            dst_tree.compress_type(*layerp) != BTRFS_COMPRESS_NONE) {
                BEESCOUNT(plan_reject_compressed_copy);
                return "compressed dst with copy";
@@ -1787,7 +1796,7 @@ scan_next_try_build_dst_plan(
        auto *layerp = ctx->layer().get();
        const auto cost = scan_next_plan_cost(layerp, plan_tree);
        const auto *reject = scan_next_reject_reason(layerp, cost,
-               dst, rewrite_policy);
+               dst, rewrite_policy, force_rewrite);
        if (reject) {
                BEESLOGC(DEBUG, Plan, "scan_next_plan rejected dst " << to_hex(dst.bytenr())
                        << " reason " << reject
@@ -3763,18 +3772,36 @@ Planner::run(const BtrfsTreeItem &bti,
                plan->finalize();
        }
 
-       // Drop non-viable plans and pick the lowest-cost winner.  The floor
-       // (BeesNullPlan or the forced FullRewrite Plan A) is unconditionally
-       // viable, so it always survives this filter: the ballot is never
-       // empty.  This is the always-true successor of the old
-       // "if (!plans.empty())" guard — assert the invariant rather than
-       // branch on it (do-nothing-plan.md §1, "Next Step 1").  Do-nothing is
-       // not a separate path: when no real plan beats debt 0, min_element
+       // Drop non-viable plans and pick the lowest-cost winner.  The
+       // do-nothing floor (BeesNullPlan) is unconditionally viable, so a
+       // non-forced ballot always survives this filter.  This is the
+       // successor of the old "if (!plans.empty())" guard (do-nothing-plan.md
+       // §1, "Next Step 1"): when no real plan beats debt 0, min_element
        // returns the null floor and scan_next_build_extent_plan yields an
        // empty ExtentPlan that scan_next_execute runs as a no-op.
        plans.erase(remove_if(plans.begin(), plans.end(),
                [](const shared_ptr<BeesPlan> &p) { return !p->viable(); }),
                plans.end());
+       // The forced FullRewrite floor is NOT unconditionally viable.  When
+       // force_start suppressed the null floor (forced_floor above), the only
+       // plan on the ballot was the forced Plan A — and that plan can be
+       // non-viable.  force-rewrite lifts the compressed-dst copy prohibition,
+       // so a compressed start can now be rewritten via copy, but the numeric
+       // op limits (copy-max, total-max, ...) still bind: an extent whose only
+       // covering exceeds those limits has no acceptable plan, the selector
+       // returns no covering, Plan A reports !viable(), and the ballot empties
+       // here.  force-rewrite is best-effort: when no covering fits within the
+       // op limits, fall back to the do-nothing floor (leave the extent as-is)
+       // rather than throw a per-extent constraint-check exception.  Re-push
+       // the null floor, which is always viable, so the winner-pick below is
+       // well-defined and scan_next_execute runs it as a no-op.
+       if (plans.empty()) {
+               BEESCOUNT(plan_force_rewrite_unrealizable);
+               BEESLOGC(DEBUG, Plan, "scan_next forced rewrite exceeds op limits, "
+                       "leaving extent as-is dst " << to_hex(m_start.bytenr()));
+               plans.push_back(make_shared<BeesNullPlan>(
+                       m_start, m_rewrite_policy, *m_filter_cache));
+       }
        THROW_CHECK1(runtime_error, plans.size(), !plans.empty());
        ExtentPlan plan;
        plan.m_start_survived = true;
index 1c9d892b197acd792113531c4b61049e5b25fc98..25c30070218aebfb6dfb758fde62a662d200f44e 100644 (file)
@@ -731,6 +731,42 @@ test_search_compressed_all_or_nothing()
        assert(res->m_copy_slices.empty());
        assert(res->m_selected_matches.size() == 1);
        assert(res->m_debt == result_recomputed_debt(in_full, policy, *res));
+
+       // force-rewrite lifts the compressed copy prohibition: a compressed dst
+       // that no dedupe covers is now rewritten via copy (the operator scoped
+       // this per filter).  No matches at all -> a single whole-extent copy.
+       auto in_force = make_inputs(10, {}, /*ref=*/1);
+       in_force.m_dst_compressed = true;
+       in_force.m_force_rewrite  = true;
+       const auto rf = scan_next_plan_shortest_path(in_force, policy);
+       assert(rf.has_value());
+       assert(rf->m_selected_matches.empty());
+       assert(rf->m_copy_slices.size() == 1);          // one copy op, no fragment
+       assert(rf->m_debt == result_recomputed_debt(in_force, policy, *rf));
+
+       // The uncovered-gap case that returned nullopt above now yields a
+       // covering under force (dedupes where matches reach, a copy across the
+       // gap).
+       auto in_gap_force = in_gap;
+       in_gap_force.m_force_rewrite = true;
+       assert(scan_next_plan_shortest_path(in_gap_force, policy).has_value());
+
+       // Op limits still bind under force.  The search enforces the total op
+       // limit during the walk (per-type copy-max is the accept authority's
+       // job), so total-max = 0 prunes the copy edge and the uncoverable
+       // compressed extent has no plan even when force-marked (the daemon then
+       // falls back to do-nothing).
+       auto tight = policy;
+       tight.m_total_max = 0;
+       assert(!scan_next_plan_shortest_path(in_force, tight).has_value());
+
+       // The high-V greedy fallback honors force the same way: below its vertex
+       // budget the uncovered compressed gap has no plan, but a force-marked
+       // extent gets its copy.  Same input, greedy vs DP, opposite outcomes.
+       auto greedy_pol = policy;
+       greedy_pol.m_plan_max_vertices = 0;   // force the greedy fallback
+       assert(!scan_next_plan_shortest_path(in_gap, greedy_pol).has_value());
+       assert(scan_next_plan_shortest_path(in_gap_force, greedy_pol).has_value());
 }
 
 static void