Claimable-stripe admission for stripe_alloc
Draft design, 2026-08-09. Addresses the churn-time writeback ENOSPC
storms: ~5.2-5.5k reserved buffered writebacks dropped per fsstress churn
run (~20 MB), silently for non-fsync writers. Stock refuses the same
writes at write() time, so this is the one regime where stripe_alloc is
strictly worse than stock.
The problem, precisely
Measured on the 8x512M raid5 fill+churn workload (dumps at every failing
find_free_extent):
- The ledger is approximately right: at failure time
total - used - pinned - reserved - may_use - ro - stripe_open -
stripe_unusable - margin was ~0, i.e. admission had clamped.
- The failing writes are stragglers: admitted while the ledger was
still positive, failing at writeback ~1-30 s later after concurrent
churn consumed the claimable supply. The storm band lasts until
hard-full stops all admission (~30 s, one commit interval).
- The failures are genuine at the instant they happen: the
stale-ordering detector saw zero index-order violations during a
5,287-failure run, and the index-independent slow-path grid walk
fails alongside the fast path. (An earlier theory that the by-size
index walk terminated early on stale keys was traced to a
measurement artifact and retracted; see
stripe-alloc-rmw-2026-08-08 notes.)
Why the existing accounting cannot close this
The reservation side is derived: free_space minus
bytes_stripe_unusable minus bytes_stripe_open, minus a pessimistic
per-extent margin held until writeback. For that arithmetic to
guarantee writeback success, bytes_stripe_unusable may never
under-count trapped bytes mid-window. The incremental accounting
(stripe_unusable_account_add) is documented to over-count returns
("bounded overcount in the safe direction"), but there are paths that
can leave it under reality between commit rescans:
- Tail claims (btrfs_claim_stripe_tail): a sub-stripe piece is
taken out of the free space cache to extend a run's frontier
stripe. The remaining free bytes of that stripe just became
trapped fragments, but nothing adds them to unusable until the
next rescan.
- Whole-stripe middles of merged returns: two adjacent returns
can complete a stripe whose halves were both counted trapped; the
rescan lowers unusable (safe), but the mirror case -- a counted-
usable whole stripe losing wholeness through a tail claim -- has
no incremental hook (case 1 again, from the other side).
- Any future allocation-side consumer of sub-stripe free space
repeats the pattern. The invariant "only frees change
fragmentation" is false as soon as any claim takes less than a
whole stripe.
Even with perfect unusable accounting there is a structural gap: the
ledger is global while the constraint is per-block-group and
per-stripe-width. A global availability of one full stripe can be
spread across block groups as sub-stripe claimable remainders in each
(mixed 320K/448K widths make this worse). Global arithmetic cannot see
that.
Proposal: admit against measured claimable supply
Stop deriving what a writeback will find; count it.
Per-block-group counter: bg->stripe_claimable = bytes of fully
free, stripe-aligned whole stripes in the free space cache (exactly
what btrfs_claim_free_stripe_run can take; the fast path's definition).
Complementary to bg->stripe_unusable: every free byte in a stripe is
claimable iff the whole stripe is free, trapped otherwise. The commit
rescan (stripe_unusable_scan) already computes per-stripe free totals
in its freep[] array -- it can emit both counters from one pass at no
extra cost, and is the authority that corrects drift each commit.
Incremental maintenance shares the sites (and the touched-stripe
classification) with the unusable accounting:
- btrfs_add_free_space: for each stripe touched by the added range,
reclassify: newly whole -> claimable += fsl (and unusable -= its
old fragment bytes, replacing today's blind fragment add);
still partial -> unusable += added fragment bytes (today's rule).
Requires reading the stripe's current free bytes: one
free-space-cache lookup per touched boundary stripe, bounded by 2 +
whole stripes per add.
- Claims (btrfs_claim_free_stripe_run fast and slow paths):
claimable -= claimed length (they take only whole stripes).
- Tail claims: the taken piece came from a partial stripe:
unusable -= piece (it was counted trapped, now allocated); the
stripe's remaining free bytes stay trapped -- already counted.
(This also fixes undercount path 1 for the unusable counter.)
- Error/backout paths (claim returned, run close tails) go through
btrfs_add_free_space and are covered by the first rule.
Accuracy rule: any site that cannot cheaply classify may err by
counting claimable LOW (move bytes to unusable) -- early write()-time
ENOSPC, corrected by the next rescan; never high.
Admission: sinfo->bytes_stripe_claimable = sum over armed bgs
(same aggregation as bytes_stripe_unusable, same rescan
reconciliation). The data reservation path admits only while
bytes_stripe_claimable >= bytes_stripe_margin + request
i.e. the claimable supply covers every already-admitted-not-yet-
allocated write's worst case (that is what bytes_stripe_margin already
tracks) plus this one. The existing derived check stays as a second
gate; effective admission is min(derived, claimable-based).
This closes the straggler window by construction: a write is admitted
only if, at admission time, enough actually claimable whole-stripe
supply exists to cover it and everyone admitted before it, and that
supply can only be consumed by those same admitted writes (plus
metadata/log claims -- see open questions).
Not counted (deliberately, v1): joinable open-run remainders. They
can satisfy same-class writers but not others (LOG/NOCOW/RELOC are
private or class-bound); ignoring them under-counts supply, which is
the safe direction. If admission proves too tight in fsync-heavy
workloads, a per-class refinement can add the COW-class shared-run
remainder.
Alternatives considered
- Bigger margin: already swept (448K/2x/4x/0): does not model
per-bg fragmentation, costs churn throughput (4x halves it), and
the margin sweep showed no effect on this failure class.
- Claimable floor (reserve N stripes admission may not touch):
simpler, but N is workload-dependent and the floor is dead capacity
at steady state; the measured storm consumed ~10 stripes in
seconds, so a safe N is not small.
- Accept and document: rejected as the default -- the drops are
silent for exactly the writers least able to notice.
Costs and risks
- Accounting cost: one extra stripe classification per free-space
add / claim, sharing work the unusable path already does; rescan
unchanged (one pass, two outputs).
- Admission tightens: near-full workloads see ENOSPC at write()
earlier than today. That is the intended trade (stock behavior).
- Lock scope: the counters live under the same locks as
stripe_unusable today (tree_lock for classification,
sinfo->lock for the aggregate); no new ordering.
- Mixed stripe widths: claimable is per-bg in that bg's fsl;
the margin unit stays the widest fsl (existing rule), so the
comparison stays conservative for narrow-width bgs.
Validation plan
- Counter-only patch first (maintain + rescan-verify, WARN on
drift): run fill/churn/balance suites; drift warnings localize any
missed site. This alone also fixes the tail-claim undercount of
bytes_stripe_unusable.
- Enable the admission gate; acceptance: fsstress churn run with
zero "returned ENOSPC despite reservation margin" warnings and
zero cow_file_range -28 on reserved COW writes, at a churn
throughput within ~10% of today's.
- Regression battery: why-rmw (seed reads must stay 0), fill-edge,
balance-reclaim, ENOSPC dataloss matrix, aligned control, raid6.
Open questions for review
- Should metadata (stripe_meta) claims draw from the same
claimable pool for admission purposes? They consume the same
stripes; v1 proposal: yes for the counter, no admission coupling
(metadata failure aborts the transaction and has its own
reservation system).
- Interaction with the fsync capacity gap: tighter admission makes
the ~2x fsync-mode capacity cost visible at write() time instead
of df lying; that is arguably a feature but changes user-visible
behavior.
- Whether the by-size fast-fail in the claim keeps its break; with
accurate claimable counters, the claim could even short-circuit on
bg->stripe_claimable == 0 before touching the index at all.