* `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.
* `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`.
// 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>
++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;
}
// 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];
// 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);
// 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).
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);
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
// 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";
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
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;
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