Skip to content

A dangling placeholder pointer makes count(*) and a scan disagree, and CHECK DATABASE calls the database clean #6292

Closed

Description

@lvca

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, gets null from getRecordInternal, 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

  1. self-assigned this
    on Aug 17, 2026
  2. added theissue type on Aug 17, 2026
  3. added this to the 26.9.1 milestone on Aug 17, 2026
  4. added 3 commits that reference this issue on Aug 17, 2026
  5. lvca commented on Aug 17, 2026

    @lvca
    MemberAuthor

    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, with FIX, the pointer is removed - which reconciles count(*) with the scan. Two new report fields, danglingPlaceholderPointers and danglingPlaceholderPointersFixed.

    Two departures from the issue's own suggestions, both deliberate:

    • The validation is a post-pass, not inline. A content record can sit on a page the walk has not reached, or is about to repair away, so asking inline makes the answer depend on the order the allocator placed the two slots in. Deferring also let the A placeholder whose CONTENT record had to spill into chunks is returned TWICE by a scan #6196 legacy reconciliation move into the same loop, which removed its "ask again" duplication: one loop over pointers, both questions, against the state the walk left.
    • The repair frees the pointer SLOT and nothing else (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_CONTENT is -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, with theSmallestPossibleContentRecordIsNotCalledDangling to fail loudly if a future serializer ever shaves those bytes.

  6. added
    engineCore engine: document/schema API
    storageStorage/paging/WAL engine layer
    on Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugengineCore engine: document/schema APIstorageStorage/paging/WAL engine layer

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions