ddt: fix refcount bypass and gang member leak for dedup gang blocks - #18819
ddt: fix refcount bypass and gang member leak for dedup gang blocks#18819Zaczero wants to merge 2 commits into
Conversation
|
@behlendorf this is the bug I noticed when working on that recent panic, and this one was likely the cause of the pool corruption I mentioned in #18811 discussion. My quick AI analysis says we can fix it proactively, before they explode, by expanding the scrub, but I haven't dug into it; just pasting as-is: The cheap one: a DDT validation pass, wired into scrub's existing DDT walk. Here's the underexploited fact: scrub already walks the DDT and reads every entry's blocks (dsl_scan_ddt — it scrubs dedup'd blocks once per entry instead of once per reference). And DDT entries are self-validating: the entry is a checksum, so reading the phys's DVAs and comparing against the stored key tells you definitively whether the phys still points at its data. A stale entry — our immortal landmine — fails that check by construction. So the detection half is nearly free: it's a natural extension of a pass that already exists and already does the I/O. The repair half is where the trap lives. The obvious repair — delete the stale entry — is wrong: any still-live BPs referencing it would then miss on free, take the pruned-entry fallback, and physically free already-freed space. You'd have rebuilt the double-free machine in the name of healing. The safe repair is the opposite: quarantine, don't delete — flag the entry so it never matches a new write (stops the future-write corruption) and never frees its extents (its refcount is inflated anyway and can't legitimately reach zero). That converts an active corruption source into a bounded, harmless leak — polarity-safe by design, because a leak is the one damage class that can't hurt you. That's small enough to be a realistic follow-up PR: a flag bit, a check in the write-path lookup and in ddt_phys_free, and the scrub hook. |
|
flaky CI fix pending in #18820 |
amotin
left a comment
There was a problem hiding this comment.
Thanks. Those are interesting.
8bbb852 to
d7a0c69
Compare
ba46372 to
48cef64
Compare
The write path stores a traditional DDT entry in the phys slot chosen by the zp_copies value the block was written with, but the free-path lookup validation (ddt_entry_lookup_is_valid()) selected the slot to compare by the BP's physical DVA count, and ddt_addref() bumped the refcount of that same wrongly-selected slot. The two indexes diverge whenever a deduplicated block's BP carries more DVAs than its copies value, which is exactly what ganging does: a gang header is stored in more copies than the data it gangs, so copies=1 blocks get a two-DVA gang header BP (unconditionally through 2.3.3, and under the default redundant_metadata=all since a46ce73 made it configurable in 2.3.4 and 9250403 in 2.4.0), and from 2.3.4 that default also gives unencrypted copies=2 blocks a three-DVA header BP. Encryption does not avoid the mismatch at copies=1: the header BP still carries two counted DVAs (the salt/IV DVA is separate). For such a block the validation checked the wrong slot, concluded the entry had been pruned, and zio_ddt_free() fell back to a plain physical free: the shared gang header extents were freed once per DDT-referenced delete with no refcount decrement. Consequences per triggering delete: remaining references point at freed header sectors and fail with gang_header checksum errors once the space is reused; the second and later deletes commit overlapping FREE records to the spacemaps ("zfs: rt=...: adding segment ... overlapping with existing one" panics at import or runtime), or free unrelated live allocations if the extents were reused in between; the gang members are never traversed and leak once the last reference is deleted; and the entry itself becomes immortal, so future identical writes dedup against freed storage. Fix the validation to match a phys by block identity (DVA[0] and physical birth) via ddt_phys_select(), which is what the decref path already uses; this also implements the existing "XXX ... maybe can combine" comment. Fix ddt_addref() the same way in the same commit: its identical mis-indexing is currently masked only by the verified lookup failing first, so fixing the lookup alone would unmask a refcount increment on the wrong slot. The verified lookup guarantees a matching phys exists, and the VERIFY keeps DDT_PHYS_NONE from reaching ddt_phys_addref(), which would index out of bounds on release builds. Incidentally this also repairs frees of legacy DDT_PHYS_DITTO blocks (the old code could never select slot 0). Since traditional tables are never pruned (pruning only walks flat tables), a free-time lookup miss on one should not happen on a consistent pool: leave a zfs_dbgmsg trace when it happens instead of silently bypassing the refcount. The free-path regression dates to d4d7945 ("Add DDT prune command"), first released in 2.3.0; before that the free path selected the phys by DVA identity. The ddt_addref() half predates it and is present in 2.2.x via block cloning. Note the code fix cannot repair damage already committed by the bug: leaked members, spacemap double frees, stale entries, and clones whose references landed in the BRT because ddt_addref() failed all persist on disk. Add a ZTS test that forces ganging on a legacy-DDT pool, takes three references to the same gang blocks (write, dedup copy, block clone), verifies the refcount of the copies=1 phys after every step, and checks the final state with zdb -bcc. It takes the clone reference with FICLONE, so it is registered Linux-only alongside the other FICLONE tests. Signed-off-by: Kamil Monicz <kamil@monicz.dev>
48cef64 to
5f45794
Compare
zio_create() adds ZIO_GANG_STAGES to the pipeline of a logical free of a gang BP, but zio_free_bp_init() replaced the whole pipeline with ZIO_DDT_FREE_PIPELINE for dedup BPs, discarding those stages. When zio_ddt_free() takes the pruned-entry fallback (the entry was legitimately removed by zpool ddtprune, or missing for any other reason), the plain free that follows only frees the gang header extents (metaslab_free_dva() frees vdev_gang_header_asize() for a gang DVA) and every gang member leaks permanently. OR the full ZIO_DDT_FREE_PIPELINE into the existing pipeline instead of replacing it. zio_free_sync() is the only creator of these zios, and for a dedup BP it already builds them with ZIO_FREE_PIPELINE and ZIO_STAGE_ISSUE_ASYNC, so today this only adds ZIO_STAGE_DDT_FREE -- but ORing the whole declaration keeps this site correct if the DDT free pipeline ever grows a stage. Adding rather than replacing also keeps any stage zio_create() adds for this BP, so a future conditional stage cannot be silently dropped here again. The gang stages still run only on the true fall-through: when the DDT handles the reference, or a BRT reference remains, those stages truncate the pipeline before the gang stages execute (since the restructuring in openzfs#18520), and the refcount-to-zero path is unaffected (ddt_phys_free() rebuilds a dedup-cleared BP whose free zio gets its own gang stages). Extend the dedup_prune_leak test with a forced-ganging phase: write gang blocks on a dedup dataset, verify they are present and ganged in the DDT, prune, delete, and verify with zdb -bcc that the members were freed. Also capture zdb's exit status in both phases so a traversal error cannot go unnoticed. Signed-off-by: Kamil Monicz <kamil@monicz.dev>
5f45794 to
1f6018b
Compare
zio_create() adds ZIO_GANG_STAGES to the pipeline of a logical free of a gang BP, but zio_free_bp_init() replaced the whole pipeline with ZIO_DDT_FREE_PIPELINE for dedup BPs, discarding those stages. When zio_ddt_free() takes the pruned-entry fallback (the entry was legitimately removed by zpool ddtprune, or missing for any other reason), the plain free that follows only frees the gang header extents (metaslab_free_dva() frees vdev_gang_header_asize() for a gang DVA) and every gang member leaks permanently. OR the full ZIO_DDT_FREE_PIPELINE into the existing pipeline instead of replacing it. zio_free_sync() is the only creator of these zios, and for a dedup BP it already builds them with ZIO_FREE_PIPELINE and ZIO_STAGE_ISSUE_ASYNC, so today this only adds ZIO_STAGE_DDT_FREE -- but ORing the whole declaration keeps this site correct if the DDT free pipeline ever grows a stage. Adding rather than replacing also keeps any stage zio_create() adds for this BP, so a future conditional stage cannot be silently dropped here again. The gang stages still run only on the true fall-through: when the DDT handles the reference, or a BRT reference remains, those stages truncate the pipeline before the gang stages execute (since the restructuring in #18520), and the refcount-to-zero path is unaffected (ddt_phys_free() rebuilds a dedup-cleared BP whose free zio gets its own gang stages). Extend the dedup_prune_leak test with a forced-ganging phase: write gang blocks on a dedup dataset, verify they are present and ganged in the DDT, prune, delete, and verify with zdb -bcc that the members were freed. Also capture zdb's exit status in both phases so a traversal error cannot go unnoticed. Reviewed-by: Brian Behlendorf <behlendorf1@llnl.gov> Reviewed-by: Alexander Motin <alexander.motin@TrueNAS.com> Signed-off-by: Kamil Monicz <kamil@monicz.dev> Closes #18819
Motivation and Context
This PR fixes two related defects in how deduplicated gang blocks are
freed and referenced.
Defect 1: traditional-DDT frees select the phys slot by BP DVA count,
bypassing refcounts.
The write path stores a traditional DDT entry in the phys slot chosen by
the logical
zp_copiesvalue:zfs/module/zfs/zio.c
Lines 3843 to 3844 in 9bf75b4
but the free-path lookup validation selects the slot to compare by the
BP's physical DVA count:
zfs/module/zfs/ddt.c
Lines 1200 to 1228 in 9bf75b4
and
ddt_addref()makes the same assumption:zfs/module/zfs/ddt.c
Lines 2692 to 2701 in 9bf75b4
The two indexes diverge whenever a block's BP carries more DVAs than
its
copiesvalue — which is exactly what ganging does. A gang headeris stored in more copies than the data it gangs, so
copies=1blocksget a two-DVA gang header BP: unconditionally through 2.3.3, and under
the default
redundant_metadata=allsince a46ce73 made gang copiesconfigurable in 2.3.4 and 9250403 in 2.4.0. From 2.3.4 that same
default also gives unencrypted
copies=2blocks a three-DVA headerBP:
https://github.com/openzfs/zfs/blob/9bf75b4b13949afe322387fc5572c5820ccL2556
Encrypted datasets diverge at the default
copies=1as well —encryption caps
gang_copiesat 2 but leavescopiesat 1:zfs/module/zfs/dmu.c
Lines 2571 to 2573 in 9bf75b4
Through 2.3.3 only
copies=1diverges; from 2.3.4 unencryptedcopies=2diverges as well, leaving justcopies=3and encryptedcopies>=2lined up.For such a block, free-time validation checks the wrong slot, concludes
the entry was previously pruned, and
zio_ddt_free()falls back to aplain physical free — the shared gang header extents are freed once
per reference delete, with no refcount decrement:
https://github.com/openzfs/zfs/blob/9bf75b4b13949afe322387fc5572c5820ccL4204
Consequences per triggering delete:
cksum_algorithm=gang_headerchecksum errors once the space isreused (all header copies are freed together, so mirror/raidz
redundancy does not protect them).
spacemaps — surfacing as
zfs: rt=...: adding segment ... overlapping with existing onepanics at import or runtime — or free unrelatedlive allocations if the extents were reallocated in between,
silently cross-linking whatever lands there next.
permanently once the last reference is deleted.
future identical writes dedup against freed/reused storage and are
lost as written.
dedup=verifymitigates this vector.lookup made
ddt_addref()fall back to BRT(module/zfs/brt.c#L1283),
leaving a DDT/BRT accounting hybrid that no later code can
disambiguate from the BP alone.
The free-path regression was introduced by d4d7945 ("Add DDT prune
command", #16277), first released in 2.3.0; before that the free
path selected the phys by DVA identity and was correct. So the hazard
is retroactive: traditional-format dedup gang blocks written safely
under 2.1/2.2 (or later, on pools without
feature@fast_dedup) becomecorruption sources the first time they are deleted under >= 2.3.0.
Exposed pools are those with a traditional-format DDT — dedup first
used while
fast_dedupwas not enabled (legacy pools that upgraded,or
compatibility=profiles) — that ever wrote a dedup'd block underenough allocation pressure to gang. Flat (FDT) tables are not affected
by the slot-indexing defect.
A possibly related field report: #17297 — the same overlapping-segment
panic class on 2.3.1 with dedup + zstd + encryption + low free space
and repeated
gang_headerchecksum ereports; no root cause wasidentified there, and its DDT format was not reported, so no direct
link is established. I also hit an
ms_deferoverlapping-segmentpanic at import on a pool with legacy-dedup history, which is what
prompted this investigation.
Defect 2: the DDT free pipeline discards gang stages.
zio_create()adds the gang stages to the pipeline of a logical freeof a gang BP:
zfs/module/zfs/zio.c
Lines 994 to 995 in 9bf75b4
but
zio_free_bp_init()replaced the whole pipeline for dedup BPs,discarding them:
https://github.com/openzfs/zfs/blob/9bf75b4b13949afe322387fc5572c5820ccL2141
On the legitimate pruned-entry fallback (
zpool ddtprune), the plainfree that follows therefore releases only the gang header extents —
metaslab_free_dva()freesvdev_gang_header_asize()for a gang DVA(module/zfs/metaslab.c#L5820-L5836)
— and every gang member leaks permanently. #17983 and #18520 fixed
adjacent problems in this fallback without covering the gang case.
Description
Two commits, independently backportable, each with its own regression
test:
ddt: select traditional phys by block identity, not BP DVA count—
ddt_entry_lookup_is_valid()now matches a phys by block identity(DVA[0] + physical birth) via
ddt_phys_select(), the samepredicate the decref path already uses (and what the existing
XXX ... maybe can combinecomment asked for).ddt_addref()selects the same way; both must change together because the addref
mis-indexing is masked only by the broken lookup failing first, and
the added
VERIFYkeepsDDT_PHYS_NONEfrom reachingddt_phys_addref(), which would index out of bounds on releasebuilds. A traditional-table miss on free — not expected on a
consistent pool, since pruning only walks flat tables
(module/zfs/ddt.c#L2841)
— now leaves a
zfs_dbgmsgtrace instead of silently bypassing therefcount. Incidentally this also repairs frees of legacy
DDT_PHYS_DITTOblocks (the old code could never select slot 0).Stale comments describing the trad slot as "the number of DVAs"
(the mental model that produced the bug) are corrected.
zio: don't strip gang stages from the DDT free pipeline—zio_free_bp_init()now ORsZIO_DDT_FREE_PIPELINEinto thepipeline instead of assigning it, keeping the gang stages (and any
other stage
zio_create()added for this BP). Today this only addsZIO_STAGE_DDT_FREE—zio_free_sync()already builds dedup freeswith every other bit of that pipeline — but ORing the whole
declaration keeps the site correct if the pipeline definition ever
grows. The gang stages still run only on the true fall-through: a
live DDT entry or a surviving BRT reference truncates the pipeline
before the gang stages execute (since the restructuring in Fix double free for blocks cloned after DDT prune #18520;
stage order
DDT_FREE < BRT_FREE < GANG_ASSEMBLE/ISSUE < DVA_FREE), and the refcount-to-zero path is unaffected(
ddt_phys_free()rebuilds a dedup-cleared BP whose free zio getsits own gang stages).
What this fix does not do. It is prevention only. Damage already
committed by the bug persists on disk and cannot be repaired in place:
leaked members (capacity only), committed spacemap double-frees (the
serious one — the allocator can hand doubly-freed regions to two
owners, and
zfs_recover=1merely warns and skips the overlappingsegment,
module/zfs/range_tree.c#L355-L366),
immortal stale entries (which keep matching future identical writes
even on fixed code — write-path lookups don't validate), and the
DDT/BRT clone hybrids. Pools with committed double-frees need
backup-and-rebuild; suggestions welcome on whether remediation guidance
belongs somewhere more durable than this PR text.
Auditing a pool:
zdb -D <pool>printsversion=0 [LEGACY]vsversion=1 [FDT]per table (object namesDDT-<cksum>-zap-*do notdiscriminate — FDT uses them too).
zdb -DDDDD <pool>(five D's —at
-DDDDthe unique/refcnt-1 class is skipped, which is wherenever-deleted hazards live). Look for LEGACY-table entries whose
physslot contradicts the printed BP, e.g.refcnt 1 phys 1 ... gang ... double. Can be expensive on largeDDTs.
zdb -bccafter last-referencedeletes;
gang_headerchecksum ereports; overlapping-segment panics.Backports: the defective validation function is byte-identical in
every 2.3.x/2.4.x release; surrounding code differs slightly per
branch (2.3.0 and 2.3.1 have no
verifylookup parameter — it arrivesin 2.3.2; the current 2.3-release tip predates master's phys-miss
fall-through), so backports need minor adaptation. The
ddt_addref()half predates 2.3.0 and exists in 2.2.x via block cloning, with no
verify-lookup masking it there; that branch deserves a separate audit.
How Has This Been Tested?
metaslab_force_gangingtunables on afeature@fast_dedup=disabledpool, userland libzpool): unpatched master leaks exactly 3,342,336
bytes per run in
zdb -bccand retains stale DDT entries after allreferences are deleted; with this change the identical workload
decrements refcounts correctly and repeated runs report no leak. The
prune/gang case (defect 2) leaks 1,198,080 bytes unpatched and is
clean with the fix.
-o metaslab_force_ganging_pct=100 on the patched tree (ztest randomly enables dedup and creates legacy/FDT pools 50/50; four runs exercised traditional DDTs), all clean through ztest's built-inzdb -bccsv` verification.dedup_legacy_gang(traditional DDT + forcedganging; three references — write, dedup copy, block clone; asserts
the refcount of the copies=1 phys after every step, that the clone
took a DDT reference and not BRT via
bcloneused, survivorreadability across an export/import, and a final empty-DDT +
zdb -bccgate) and a forced-ganging second phase indedup_prune_leak(asserts the entries are ganged and actuallypruned, then verifies the members are freed).
dedup_legacy_gangtakes its clone reference with
FICLONE, so it is registeredLinux-only alongside the other FICLONE tests. Both follow existing
sibling-test idioms; I have not yet run the full suite against a
live kernel with this change, so CI is the first executor — flagging
that honestly.
-Werror, debug),make checkstyleclean(cstyle, shellcheck, commitcheck),
git diff --checkclean.Types of Changes
Checklist
Signed-off-by.