Level-2 write hole: parity update dropped (DW_SURGERY=parity)

2026-08-04, kernel 7.2.0-rc5+ #86 (master testing + zb64), 3-device raid5 data / raid1 metadata, directed-writehole.sh DW_SURGERY=parity.

What this variant tests

The original directed test (DW_SURGERY=data, the default) drops a data block of an in-flight write and shows that a committed neighbour sharing the full stripe becomes unrecoverable. That is the write hole most people mean.

This variant drops the parity update instead: the data sectors of the in-flight write reach the disk, the parity block for that row does not. The same dm-log-writes replay machinery is used -- replay to the afterA mark, then splice the pre-transaction contents of exactly one physical block back over the final image. The block is chosen from the chunk map:

data column  ->  member (fsn + col) % 3
parity       ->  member (fsn + 2) % 3        # ndata = 2, 3 devices

so DW_SURGERY=parity recomputes the physical target as the parity member of the same row the write touched, and reverts that one block.

Result

########## legacy ##########
f1 @ 105644032, f17 @ 105709568 (logical)
OK: stock allocator packed f17 into the committed stripe
f17: fullstripe 267 col 1 mode parity -> member 2 phys 67174400
spliced pre-txn-2 block over the parity update of f17's row
all-present reads clean: stale parity is LATENT
dropping member 0 (holds f1's data column)
degraded read of committed f1: f1 EIO (torn parity detected by csum)
OK: WRITE HOLE demonstrated deterministically at 3 devices
degraded read of writing-file f17: CORRECT

########## stripe_alloc ##########
f1 @ 105644032, f17 @ 105775104 (logical)
OK: stripe_alloc kept f17 out of the committed stripe
f17: fullstripe 268 col 0 mode parity -> member 0 phys 39583744
spliced pre-txn-2 block over the parity update of f17's row
all-present reads clean: stale parity is LATENT
dropping member 0 (holds f1's data column)
degraded read of committed f1: f1 CORRECT (reconstructed through consistent parity)
OK: stripe_alloc survives the surgery
degraded read of writing-file f17: CORRECT

DIRECTED_RC=0

Reading

  1. It is a real second hole, not a variation of the first. Legacy loses the committed neighbour f1 exactly as in the data-dropped case, by a different mechanism: nothing about f1's own bytes was touched, and no f1 block was lost. The damage is entirely in the redundancy: parity now describes col1_old while the disk holds col1_new, so reconstruction computes P_old ^ col1_new = col0 ^ col1_old ^ col1_new -- garbage.

  2. It is latent. all-present reads clean in both legs: with every device present, btrfs never reads parity, so nothing detects the divergence. Scrub would (it recomputes parity), an ordinary read never will. The corruption is created at crash time and stays invisible until a device is lost, which is precisely when it is least welcome.

  3. The victim's csum decided whether it was loud or silent. Here f1 is ordinary csummed data, so reconstruction produced garbage, the csum failed, and the read returned EIO -- loud, and the data is still gone. A victim without csums (nodatasum/nodatacow file, or free space that a later allocation trusts) would have been reconstructed to garbage silently. That is the worse half of this failure mode and is not exercised here.

  4. stripe_alloc confines it to the writer's own stripe. Under the series f17 was placed in full stripe 268 rather than joining f1 in 267, so the reverted parity belongs to a stripe with no committed data in it. f1 reconstructs through stripe 267's parity, which the crash never touched: f1 CORRECT. This is the same confinement argument as the data-dropped case and it holds for the same structural reason -- a committed stripe is never rewritten, so nothing in flight can invalidate its parity.

  5. The writer's own file survives in both legs (f17: CORRECT). Not a property of either allocator: f17's data column is on a surviving member and is read directly, no reconstruction involved. Recorded so the line is not mistaken for a stripe_alloc guarantee.

Why it took a separate run to find out

The level-2 case was assumed to be covered by the level-1 result and had never actually been executed. It is worth keeping as a distinct gate: the two modes exercise different halves of the RMW write (data vs parity), and a fix that happened to protect one could leave the other open.

Companion: directed-writehole.sh (DW_SURGERY=data|parity), NOTE-b-eio-cluster.md, NOTE-stripe-alloc-reservation-and-reclaim.md.