Repository navigation
Description
Found while building the #6196 fixtures (PR #6287), and confirmed on main at a03af88.
What happens
CHECK DATABASE FIX force-deletes a record whose multi-page chunk chain is broken, and leaves the placeholder
POINTER that referenced it behind. Nothing ever notices again:
select count(@rid) as c from P --> 7 (a scan: the pointer resolves to nothing and is skipped)
select count(*) as c from P --> 8 (the cached counter: a pointer slot is a record)
check database --> totalErrors: 0
The two counts disagree permanently, and the checker reports the database as clean.
Why
LocalBucket.check() walks every slot and classifies it by marker. For RECORD_PLACEHOLDER_POINTER it counts a
placeholder and moves on - it never follows the pointer to ask whether anything is there. (Since #6196 it
does read the target's marker, but only to recognise the pre-#6196 ambiguous content shape; a target that is
GONE is not looked at.)
So when the broken-chain branch force-deletes a content record - deleteRecordInternal(rid, true, true, true),
which frees the content head's slot and nothing else - the pointer on the other page is orphaned in place. From
then on:
scan()resolves the pointer, getsnullfromgetRecordInternal, and skips the slot: the record is gone
from every query.count()counts the slot, because its rule is> 0 || RECORD_PLACEHOLDER_POINTER || FIRST_CHUNK, and a
pointer is a record as far as it is concerned.check()counts a placeholder and reports no error.
The same hole swallows a pointer orphaned any other way (a content record lost to physical corruption, an
interrupted repair).
Reproduction
Fixtures already in the tree: BucketPageLayoutTestSupport.sealFirstPage plus a 200 KB value produces a
placeholder whose content is a chunk chain (see Issue6196PlaceholderContentChainTest). Break that chain's
continuation pointer, run CHECK DATABASE FIX, then compare count(@rid) against count(*).
Worth considering
The narrow repair is to make the FIX delete the pointer along with the content it can no longer reach - the
pointer is the record's identity, and a record whose content is beyond repair is a record to remove, which is
what the broken-chain branch already decided.
The better one is to give check() the validation it is missing, because the deletion is only one of the ways a
pointer is orphaned: follow every RECORD_PLACEHOLDER_POINTER and report a target that is not a content record
(deleted slot, out-of-range position, a marker that is not a negated size or
FIRST_CHUNK_PLACEHOLDER_CONTENT). #6196 already pays the page fetch per pointer to read that marker, so the
question costs nothing new - only the branch that asks it is missing. FIX then removes the pointer, which
reconciles count(*) with the scan and lets the checker answer honestly.
Activity
Merge origin/main into issue-6286-6292-6293-6294
fix(#6292): the -5 marker boundary cannot cost a live pointer
fix(#6286, #6292, #6293, #6294): a content record collapses back like…
lvca commentedon Aug 17, 2026
Fixed by #6299 (merged as 0efa999).
check()now follows every placeholder POINTER and classifies its target: a content record of either shape, the pre-#6196 ambiguous head, or nothing a pointer may lead to. The last is reported with both RIDs and, withFIX, the pointer is removed - which reconcilescount(*)with the scan. Two new report fields,danglingPlaceholderPointersanddanglingPlaceholderPointersFixed.Two departures from the issue's own suggestions, both deliberate:
freeSlotOnly), where the narrow proposal would have gone through the ordinary delete. That distinction is load-bearing and has a test of its own: a pointer can be dangling because it was CORRUPTED, in which case it now names whatever record happens to live at that position, and the ordinary delete would follow it and remove that record.A pointer left dangling by a deletion the same run made is removed with it and not booked as a second error - the record had one defect.
One boundary hardened on review:
RECORD_PLACEHOLDER_CONTENTis -5 and every classification site excludes it with a strict<, so a 5-byte content body would have been read as not-content and its pointer freed. Measured as unreachable today (the smallest document is 6 bytes), but guarded at both ends anyway, withtheSmallestPossibleContentRecordIsNotCalledDanglingto fail loudly if a future serializer ever shaves those bytes.