]> git.hungrycats.org Git - bees/commitdiff
scan-next: abandon dst plans quietly when a source ref vanishes
authorZygo Blaxell <bees@furryterror.org>
Tue, 4 Aug 2026 17:48:39 +0000 (13:48 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:14 +0000 (00:04 -0400)
The sweep-join pair builder threw "Plan incomplete: no valid source
reference covers range" when a planned source window had no usable
ref, producing a full trace dump with two extent tree prints per
occurrence.  This is not a planner bug: the plan is built from one
transid cycle's cached snapshot, and layer caches are bulk-cleared at
transid change, so by execution time the refetched refs list can be
missing refs that were deleted on disk in between (file range
overwritten, file or snapshot deleted).  Nothing can prevent this
race — filesystem users delete refs whenever they like — so it is
expected churn, like the unopenable refs excluded from block map
coverage during map generation.

Replace the throw with a quiet abandon: count the failure, log a
single debug line, and return an empty optional through
scan_next_build_tree_pairs so scan_next_execute_dst_plan skips the
dst without executing any pairs.  The abandon happens during pair
building, before any FIDEDUPERANGE runs, so the dst extent is left
untouched and gets rescanned in a later cycle once the churn
settles.  Abandoning the whole dst rather than skipping the
uncovered range keeps the plan's cost model honest: partial
execution would create refs without freeing the dst extent.

Two counters split the failure by class, mirroring the pread
instrumentation: plan_src_gone (no covering ref at all — the ref
vanished from the refs list) and plan_src_open_fail (covering refs
present but none opened — deleted-but-listed churn).

Assisted-by: Claude-Code:claude-fable-5
docs/event-counters.md
src/bees-scan-next.cc

index c281b9685484bd262f23f3cb5d01205e5be8904c..de56c5d5f8f0813d3c4aaf9e6f5cbfe1ae97a229 100644 (file)
@@ -332,6 +332,8 @@ The `plan` event group consists of events within the `scan_next` planner: choosi
  * `plan_reject_dedupe_max`: A composed plan was rejected because its dedupe operation count exceeded `dedupe-max`.
  * `plan_reject_hole_max`: A composed plan was rejected because its hole-punch operation count exceeded `hole-max`.
  * `plan_reject_total_max`: A composed plan was rejected because its total operation count exceeded `total-max`.
+ * `plan_src_gone`: A dst plan was abandoned before executing because a planned source window had no covering ref at all — the ref vanished from the refetched refs list (file range overwritten, file or snapshot deleted) between planning and execution.  Expected filesystem churn; the extent is rescanned in a later cycle.
+ * `plan_src_open_fail`: A dst plan was abandoned before executing because every ref covering a planned source window failed to open (deleted files or snapshots lingering in the refs list).  Expected filesystem churn; the extent is rescanned in a later cycle.
  * `plan_start`: A planner pass over one start extent began.
  * `plan_worse_than_null`: The winning plan had a space debt above the do-nothing floor — an invariant violation guard; for a non-forced start the null floor should always win in that case.
 
index 56b5ec9662d4dd515b0edb63b9ccddb985526e55..fd342d04c44c8e1d23d2dd14a95a73a2168876c5 100644 (file)
@@ -812,7 +812,17 @@ scan_next_build_all_ref_slices(BeesContext &, const BeesExtent &extent,
 /// a full ref [0, N) from file A and partial refs [X, X+Y) from
 /// file B — all of which need FIDEDUPERANGE calls to fully replace
 /// the old extent.
-static vector<BeesRangePair>
+///
+/// Returns an empty optional when a planned source window has no
+/// usable ref: the plan was built from a previous transid cycle's
+/// snapshot, and the covering ref has since vanished from the
+/// refetched refs list (file range overwritten, file or snapshot
+/// deleted) or stopped opening.  The caller must abandon the dst
+/// plan without executing any pairs — partial execution would create
+/// refs without freeing the dst extent, breaking the cost model the
+/// plan was accepted under.  The extent is rescanned in a later
+/// cycle once the churn settles.
+static optional<vector<BeesRangePair>>
 scan_next_sweep_join_pairs(BeesContext &ctx,
        const vector<BeesTreeRefSlice> &dst_refs,
        const BeesExtent &src_tree,
@@ -849,12 +859,14 @@ scan_next_sweep_join_pairs(BeesContext &ctx,
                uint64_t cursor = dst_ref.begin();
                while (cursor < dst_ref.end()) {
                        bool covered = false;
+                       bool open_failed = false;
                        uint64_t best_overlap_end = cursor;
 
                        const auto src_sub_slice = BeesExtentSlice(src_tree, cursor, dst_ref.end());
                        src_sub_slice.refs(ctx.layer().get(), [&](BeesExtentLayer *, const BeesTreeRefSlice &src_ref) {
                                const auto src_fd = src_ref.ref().open(ctx);
                                if (!src_fd) {
+                                       open_failed = true;
                                        return true; // Keep walking to try other overlapping fallbacks
                                }
                                const auto overlap_end = min(src_ref.end(), dst_ref.end());
@@ -875,8 +887,20 @@ scan_next_sweep_join_pairs(BeesContext &ctx,
                        }, BeesVisitorPath::src);
 
                        if (!covered) {
-                               THROW_ERROR(runtime_error, "Plan incomplete: no valid source reference covers range ["
-                                       << to_hex(cursor) << ".." << to_hex(dst_ref.end()) << ") for dst " << dst_ref);
+                               // Concurrent filesystem modification invalidated the
+                               // plan: expected churn, not an error, so no exception
+                               // and no trace dump — count, log one line, abandon.
+                               if (open_failed) {
+                                       BEESCOUNT(plan_src_open_fail);
+                               } else {
+                                       BEESCOUNT(plan_src_gone);
+                               }
+                               BEESLOGC(DEBUG, Plan, "scan_next abandon dst plan: no "
+                                       << (open_failed ? "openable" : "covering")
+                                       << " src ref for [" << to_hex(cursor)
+                                       << ".." << to_hex(dst_ref.end())
+                                       << ") of dst " << dst_ref);
+                               return {};
                        }
 
                        cursor = best_overlap_end;
@@ -885,7 +909,7 @@ scan_next_sweep_join_pairs(BeesContext &ctx,
        return rv;
 }
 
-vector<BeesRangePair>
+optional<vector<BeesRangePair>>
 scan_next_build_tree_pairs(BeesContext &ctx, const BeesExtent &prepared,
        const BeesExtent &src_tree, uint64_t reachable_size,
        optional<bool> want_prealloc = {})
@@ -2924,11 +2948,22 @@ scan_next_execute_dst_plan(const shared_ptr<BeesContext> &ctx,
 
        // Build dedupe pairs: prealloc first, then data.  Pass reachable_size
        // so dst-ref enumeration stops at the mid-block EOF and never demands a
-       // source for the bare past-EOF tail hole.
-       auto prealloc_pairs = scan_next_build_tree_pairs(*ctx, dst,
+       // source for the bare past-EOF tail hole.  An empty optional means a
+       // planned src window lost its last usable ref to concurrent filesystem
+       // modification (counted and logged in the sweep join) — abandon this
+       // dst before executing anything; it can be rescanned in a later cycle.
+       auto prealloc_pairs_opt = scan_next_build_tree_pairs(*ctx, dst,
                src_tree, reachable_size, true);
-       auto data_pairs = scan_next_build_tree_pairs(*ctx, dst,
+       if (!prealloc_pairs_opt) {
+               return false;
+       }
+       auto data_pairs_opt = scan_next_build_tree_pairs(*ctx, dst,
                src_tree, reachable_size, false);
+       if (!data_pairs_opt) {
+               return false;
+       }
+       auto &prealloc_pairs = *prealloc_pairs_opt;
+       auto &data_pairs = *data_pairs_opt;
 
        BEESLOGC(DEBUG, Plan, "scan_next_execute_dst_plan dst "
                << to_hex(dst.bytenr())