]> git.hungrycats.org Git - bees/commitdiff
test: guard against rewriting a zero-gain EOF-tail extent
authorZygo Blaxell <bees@furryterror.org>
Fri, 24 Jul 2026 13:19:25 +0000 (09:19 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:14 +0000 (00:04 -0400)
An extent whose reachable data ends mid-block at EOF has a past-EOF
unreachable tail.  When that tail is entirely sub-block it frees no whole
block, so a cleanup copy that relocates the data gains nothing and must
lose to do-nothing.  The phantom-block bug rounded the tail up to one
freeable block, so at free-min 50% a single-block extent was rewritten for
pure ref churn.

Add an integration case built from btrfs's inability to partially free a
data extent: write, commit, truncate within the extent, commit -- leaving
whole blocks past EOF allocated-but-unreachable for bees to reclaim.  A
setup assertion confirms the physical allocation stayed whole by reading
the EXTENT_ITEM key length, so a kernel that ever frees the tail eagerly
fails loudly rather than silently voiding the fixture.

Three cases at free-min 50%:
  A  4096 -> 2990   0 whole blocks freed -> must NOT be rewritten
  B  8192 -> 2990   1 whole block  freed -> rewritten (compacted)
  C 16384 -> 6000   2 whole blocks freed -> rewritten (compacted)

A is the regression guard (the only shape that flips at 50%); B and C
prove real gains still fire and exercise the executor's bare past-EOF tail
path.  free-min is pinned to 50% so the test is independent of any host
global rewrite.free-min.  Validated on the integration host: passes on
current HEAD, and A is rewritten (fails) at the pre-fix commit.

Assisted-by: Claude-Code:claude-opus-4-8
test/integration/cases/21-eof-tail-no-churn.sh [new file with mode: 0755]

diff --git a/test/integration/cases/21-eof-tail-no-churn.sh b/test/integration/cases/21-eof-tail-no-churn.sh
new file mode 100755 (executable)
index 0000000..df75940
--- /dev/null
@@ -0,0 +1,171 @@
+#!/bin/bash
+# Test 21: an EOF-tail extent is rewritten only when it frees a WHOLE block
+#
+# Regression guard for the phantom-unreachable-block bug (cf3959bc4) and a
+# runtime exercise of the past-EOF temp elision (f04f91a14).  Runs at the
+# DEFAULT free-min = 50%.
+#
+# MECHANISM
+#   btrfs does not partially free a data extent: truncating a file within an
+#   extent leaves the whole physical extent allocated and only lowers
+#   i_size, so whole blocks past EOF become allocated-but-unreachable.  bees
+#   reclaims them by COPYING the reachable data to a compact extent (a
+#   cleanup plan -- no dedupe, viable with no candidates).  The gain is the
+#   number of COMPLETE unreachable blocks; a sub-block past-EOF tail shares
+#   its block with reachable data and frees nothing.  That count is the
+#   floor() in scan_next_region_block_count.
+#
+#   For an extent of E blocks with reachable data D blocks (= bad, copied)
+#   and U complete unreachable blocks (= good, reclaimed), U = E - D, and
+#   free-min at 50% passes iff good >= bad, i.e. U >= D, i.e. retained data
+#   is at most half the extent.
+#
+# THE BUG: the unreachable region was rounded UP, so a sub-block-only tail
+#   counted as 1 phantom freeable block.  On a SINGLE-block extent that
+#   phantom is decisive: good_pre = 1 = bad clears free-min at exactly 50%
+#   and the negative debt rewrites the extent for zero net gain (pure ref
+#   churn).  Post-fix the tail rounds DOWN to 0 -> free-min rejects and
+#   do-nothing wins.  (Only E=1 flips at 50%; for E>=2 the phantom never
+#   cleared free-min, so the multi-block cases here test that REAL gains
+#   still fire and the executor handles the past-EOF tail, not the flip.)
+#
+# WHY A COPY, NOT A DEDUPE: each file is independent random data, so there
+#   is no duplicate to reference -- the only plan is the cleanup copy.  A
+#   dedupe would correctly have negative debt and is not what regressed.
+#
+# CASES (all at default free-min = 50%):
+#   A  4096 -> 2990   E=1 D=1 U=0   NOT rewritten (post) / rewritten (pre)  <- guard
+#   B  8192 -> 2990   E=2 D=1 U=1   rewritten (1-block gain, floor of mixed block)
+#   C 16384 -> 6000   E=4 D=2 U=2   rewritten (2-block gain, floor of mixed block)
+#
+# Prerequisites: testlib.sh, a configured host.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
+source "$SCRIPT_DIR/../lib/testlib.sh"
+
+IT_TEST_NAME="21-eof-tail-no-churn"
+
+it_load_config
+it_setup_trap
+
+BEESHOME="$TEST_MOUNT/.beeshome"
+IT_BEES_LOGFILE="$TEST_LOG_DIR/${IT_RUN_ID}.log"
+
+echo "=== $IT_TEST_NAME ==="
+it_deploy
+
+it_ssh_script <<REMOTE
+set -euo pipefail
+
+disk_bytenr() {
+    btrfs-search-metadata file "\$1" 2>/dev/null | \
+        awk '/disk_bytenr/ {for (i=1;i<=NF;i++) if (\$i=="disk_bytenr") {print \$(i+1); exit}}'
+}
+one_extent() { [ "\$(filefrag "\$1" | grep -oE '[0-9]+ extent' | grep -oE '^[0-9]+')" = 1 ]; }
+# Physical allocation length = the EXTENT_ITEM key's offset field (the
+# extent's item-header offset), read from the extent tree.  Independent of
+# any file's i_size / reference length.  dump-tree prints it as
+#   item N key (<bytenr> EXTENT_ITEM <length>) ...
+extent_len() {  # bytenr
+    btrfs inspect-internal dump-tree -t extent '${TEST_SCRATCH_DEV}' 2>/dev/null | \
+        grep -F "(\$1 EXTENT_ITEM " | head -1 | \
+        sed -E 's/.*EXTENT_ITEM ([0-9]+)\).*/\1/'
+}
+
+# ── Fresh fs ─────────────────────────────────────────────────────────────────
+umount -l '${TEST_MOUNT}' 2>/dev/null || true
+mkfs.btrfs -f '${TEST_SCRATCH_DEV}'
+mkdir -p '${TEST_MOUNT}'
+mount -o subvolid=5 '${TEST_SCRATCH_DEV}' '${TEST_MOUNT}'
+cd '${TEST_MOUNT}'
+
+# One file per case, DISTINCT random content (no cross-file dedupe), each a
+# single extent.  write -> commit -> truncate -> commit.
+make_case() {  # name  write_bytes  truncate_to
+    head -c "\$2" /dev/urandom > "\$1"
+    btrfs filesystem sync '${TEST_MOUNT}'
+    one_extent "\$1" || { echo "[FAIL] \$1 write fragmented; premise broken" >&2; exit 1; }
+    truncate -s "\$3" "\$1"
+    btrfs filesystem sync '${TEST_MOUNT}'
+    # PREMISE: btrfs never partially frees a data extent, so the physical
+    # allocation (EXTENT_ITEM key length) stays at the written size even
+    # though i_size shrank -- that leftover is the unreachable space bees
+    # reclaims.  This is thoroughly baked into current btrfs on-disk design;
+    # only a format change (e.g. extent_tree_v2) could relax it, and that
+    # would require significant bees planner work throughout.  So a failure
+    # HERE is a canary for a changed btrfs, not a flaky test -- assert it at
+    # setup rather than let cases B/C silently stop reclaiming.
+    local bn plen
+    bn="\$(disk_bytenr "\$1")"
+    plen="\$(extent_len "\$bn")"
+    [ "\$plen" = "\$2" ] || { echo "[FAIL] \$1 extent shrank on truncate: EXTENT_ITEM len \$plen != written \$2 -- btrfs partial-free premise broken" >&2; exit 1; }
+    echo "[premise] \$1: i_size \$3, physical extent still \$plen bytes"
+}
+make_case a_regression  4096  2990    # E=1 D=1 U=0 -> 0 complete unreachable blocks
+make_case b_one_block   8192  2990    # E=2 D=1 U=1 -> 1 complete unreachable block
+make_case c_two_block  16384  6000    # E=4 D=2 U=2 -> 2 complete unreachable blocks
+
+echo "A_PRE=\$(disk_bytenr a_regression)" >  /tmp/pre.env
+echo "B_PRE=\$(disk_bytenr b_one_block)"  >> /tmp/pre.env
+echo "C_PRE=\$(disk_bytenr c_two_block)"  >> /tmp/pre.env
+cat /tmp/pre.env
+
+# ── bees config: DEFAULT free-min (50%), one deterministic pass ──────────────
+mkdir -p '${BEESHOME}' '${TEST_LOG_DIR}'
+cat > '${BEESHOME}/bees.conf' <<'CONF'
+[log.plan]
+level = 8
+[loop]
+exit-one-pass = yes
+[thread]
+thread-max = 1
+[scan.extent.*]
+method = 1
+filter = ACCEPT
+[rewrite]
+free-min = 50%
+CONF
+# free-min is pinned to 50% (the schema default and the production regime
+# in which the regression occurred) so the test is hermetic: the host may
+# carry a global /etc/bees rewrite.free-min (e.g. 128K) that bees merges
+# under this per-run config, and at 128K the 'cap threshold at extent size'
+# clause would require freeing 100% of any sub-128K extent -- masking both
+# the bug and the real-gain cases.  At 50%, B/C (free exactly half) pass
+# and A (frees nothing) is declined.
+
+'${REMOTE_BEES_BIN}/bees' --config '${BEESHOME}/bees.conf' '${TEST_MOUNT}' \
+    > '${IT_BEES_LOGFILE}' 2>&1 &
+wait \$! || true
+
+btrfs filesystem sync '${TEST_MOUNT}'
+{
+    echo "A_POST=\$(disk_bytenr a_regression)"
+    echo "B_POST=\$(disk_bytenr b_one_block)"
+    echo "C_POST=\$(disk_bytenr c_two_block)"
+} > '${TEST_LOG_DIR}/${IT_RUN_ID}.results'
+cat '${TEST_LOG_DIR}/${IT_RUN_ID}.results'
+REMOTE
+
+# ── Assertions ───────────────────────────────────────────────────────────────
+eval "$(it_ssh "cat /tmp/pre.env; cat '${TEST_LOG_DIR}/${IT_RUN_ID}.results'")"
+
+it_assert_no_errors "run: no exceptions / Plan incomplete / crash"
+it_assert_bees_log_grep "run: planner evaluated extents" "scan_next_log_plan start bytenr"
+
+# THE FIX: a 0-whole-block EOF tail is declined even at 50% free-min.
+it_assert "A: 0-block EOF tail NOT rewritten (bytenr stable)" \
+    test "$A_PRE" = "$A_POST"
+
+# Real gains still fire at 50% free-min -- proves bees was active (so A's
+# stability is a genuine decline) and drives the executor's bare-past-EOF
+# tail path (f04f91a14); it_assert_no_errors catches any Plan-incomplete.
+it_assert "B: 1-block gain rewritten" test "$B_PRE" != "$B_POST"
+it_assert "C: 2-block gain rewritten" test "$C_PRE" != "$C_POST"
+
+# Strongest form (optional): re-deploy cf3959bc4^ and assert A IS rewritten
+# there -- proving the fixture bites pre-fix.  A is the only case that flips
+# at 50% free-min (see header), so it is the sole differential signal.
+
+it_summary